first commit
This commit is contained in:
64
api/.env.example
Normal file
64
api/.env.example
Normal 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
366
api/CLAUDE.md
Normal 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
35
api/Dockerfile
Normal 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
204
api/README.md
Normal 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
171
api/app/__init__.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Flask application factory for Code of Conquest.
|
||||
|
||||
Creates and configures the Flask application instance.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from app.config import get_config
|
||||
from app.utils.logging import setup_logging, get_logger
|
||||
|
||||
|
||||
def create_app(environment: str = None) -> Flask:
|
||||
"""
|
||||
Application factory pattern for creating Flask app.
|
||||
|
||||
Args:
|
||||
environment: Environment name (development, production, etc.)
|
||||
If None, uses FLASK_ENV from environment variables.
|
||||
|
||||
Returns:
|
||||
Flask: Configured Flask application instance
|
||||
|
||||
Example:
|
||||
>>> app = create_app('development')
|
||||
>>> app.run(debug=True)
|
||||
"""
|
||||
# Get the path to the project root (parent of 'app' package)
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Create Flask app with correct template and static folders
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=os.path.join(project_root, 'templates'),
|
||||
static_folder=os.path.join(project_root, 'static')
|
||||
)
|
||||
|
||||
# Load configuration
|
||||
config = get_config(environment)
|
||||
|
||||
# Configure Flask from config object
|
||||
app.config['SECRET_KEY'] = config.secret_key
|
||||
app.config['DEBUG'] = config.app.debug
|
||||
|
||||
# Set up logging
|
||||
setup_logging(
|
||||
log_level=config.logging.level,
|
||||
log_format=config.logging.format,
|
||||
log_file=config.logging.file_path if 'file' in config.logging.handlers else None
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger.info(
|
||||
"Starting Code of Conquest",
|
||||
version=config.app.version,
|
||||
environment=config.app.environment
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
CORS(app, origins=config.cors.origins)
|
||||
|
||||
# Store config in app context
|
||||
app.config['COC_CONFIG'] = config
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Register blueprints (when created)
|
||||
register_blueprints(app)
|
||||
|
||||
logger.info("Application initialized successfully")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask) -> None:
|
||||
"""
|
||||
Register global error handlers for the application.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
from app.utils.response import (
|
||||
error_response,
|
||||
internal_error_response,
|
||||
not_found_response
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@app.errorhandler(404)
|
||||
def handle_404(error):
|
||||
"""Handle 404 Not Found errors."""
|
||||
logger.warning("404 Not Found", path=error.description)
|
||||
return not_found_response()
|
||||
|
||||
@app.errorhandler(500)
|
||||
def handle_500(error):
|
||||
"""Handle 500 Internal Server errors."""
|
||||
logger.error("500 Internal Server Error", error=str(error), exc_info=True)
|
||||
return internal_error_response()
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(error):
|
||||
"""Handle uncaught exceptions."""
|
||||
logger.error(
|
||||
"Uncaught exception",
|
||||
error=str(error),
|
||||
error_type=type(error).__name__,
|
||||
exc_info=True
|
||||
)
|
||||
return internal_error_response()
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
"""
|
||||
Register Flask blueprints (API routes and web UI views).
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ===== API Blueprints =====
|
||||
|
||||
# Import and register health check API blueprint
|
||||
from app.api.health import health_bp
|
||||
app.register_blueprint(health_bp)
|
||||
logger.info("Health API blueprint registered")
|
||||
|
||||
# Import and register auth API blueprint
|
||||
from app.api.auth import auth_bp
|
||||
app.register_blueprint(auth_bp)
|
||||
logger.info("Auth API blueprint registered")
|
||||
|
||||
# Import and register characters API blueprint
|
||||
from app.api.characters import characters_bp
|
||||
app.register_blueprint(characters_bp)
|
||||
logger.info("Characters API blueprint registered")
|
||||
|
||||
# Import and register sessions API blueprint
|
||||
from app.api.sessions import sessions_bp
|
||||
app.register_blueprint(sessions_bp)
|
||||
logger.info("Sessions API blueprint registered")
|
||||
|
||||
# Import and register jobs API blueprint
|
||||
from app.api.jobs import jobs_bp
|
||||
app.register_blueprint(jobs_bp)
|
||||
logger.info("Jobs API blueprint registered")
|
||||
|
||||
# Import and register game mechanics API blueprint
|
||||
from app.api.game_mechanics import game_mechanics_bp
|
||||
app.register_blueprint(game_mechanics_bp)
|
||||
logger.info("Game Mechanics API blueprint registered")
|
||||
|
||||
# Import and register travel API blueprint
|
||||
from app.api.travel import travel_bp
|
||||
app.register_blueprint(travel_bp)
|
||||
logger.info("Travel API blueprint registered")
|
||||
|
||||
# Import and register NPCs API blueprint
|
||||
from app.api.npcs import npcs_bp
|
||||
app.register_blueprint(npcs_bp)
|
||||
logger.info("NPCs API blueprint registered")
|
||||
|
||||
# TODO: Register additional blueprints as they are created
|
||||
# from app.api import combat, marketplace, shop
|
||||
# app.register_blueprint(combat.bp, url_prefix='/api/v1/combat')
|
||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')
|
||||
61
api/app/ai/__init__.py
Normal file
61
api/app/ai/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
AI integration module for Code of Conquest.
|
||||
|
||||
This module contains clients and utilities for AI-powered features
|
||||
including narrative generation, quest selection, and NPC dialogue.
|
||||
"""
|
||||
|
||||
from app.ai.replicate_client import (
|
||||
ReplicateClient,
|
||||
ReplicateResponse,
|
||||
ReplicateClientError,
|
||||
ReplicateAPIError,
|
||||
ReplicateRateLimitError,
|
||||
ReplicateTimeoutError,
|
||||
ModelType,
|
||||
)
|
||||
|
||||
from app.ai.model_selector import (
|
||||
ModelSelector,
|
||||
ModelConfig,
|
||||
UserTier,
|
||||
ContextType,
|
||||
)
|
||||
|
||||
from app.ai.prompt_templates import (
|
||||
PromptTemplates,
|
||||
PromptTemplateError,
|
||||
get_prompt_templates,
|
||||
render_prompt,
|
||||
)
|
||||
|
||||
from app.ai.narrative_generator import (
|
||||
NarrativeGenerator,
|
||||
NarrativeResponse,
|
||||
NarrativeGeneratorError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Replicate client
|
||||
"ReplicateClient",
|
||||
"ReplicateResponse",
|
||||
"ReplicateClientError",
|
||||
"ReplicateAPIError",
|
||||
"ReplicateRateLimitError",
|
||||
"ReplicateTimeoutError",
|
||||
"ModelType",
|
||||
# Model selector
|
||||
"ModelSelector",
|
||||
"ModelConfig",
|
||||
"UserTier",
|
||||
"ContextType",
|
||||
# Prompt templates
|
||||
"PromptTemplates",
|
||||
"PromptTemplateError",
|
||||
"get_prompt_templates",
|
||||
"render_prompt",
|
||||
# Narrative generator
|
||||
"NarrativeGenerator",
|
||||
"NarrativeResponse",
|
||||
"NarrativeGeneratorError",
|
||||
]
|
||||
226
api/app/ai/model_selector.py
Normal file
226
api/app/ai/model_selector.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Model selector for tier-based AI model routing.
|
||||
|
||||
This module provides intelligent model selection based on user subscription tiers
|
||||
and context types to optimize cost and quality.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
|
||||
import structlog
|
||||
|
||||
from app.ai.replicate_client import ModelType
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class UserTier(str, Enum):
|
||||
"""User subscription tiers."""
|
||||
FREE = "free"
|
||||
BASIC = "basic"
|
||||
PREMIUM = "premium"
|
||||
ELITE = "elite"
|
||||
|
||||
|
||||
class ContextType(str, Enum):
|
||||
"""Types of AI generation contexts."""
|
||||
STORY_PROGRESSION = "story_progression"
|
||||
COMBAT_NARRATION = "combat_narration"
|
||||
QUEST_SELECTION = "quest_selection"
|
||||
NPC_DIALOGUE = "npc_dialogue"
|
||||
SIMPLE_RESPONSE = "simple_response"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
"""Configuration for a selected model."""
|
||||
model_type: ModelType
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
"""Get the model identifier string."""
|
||||
return self.model_type.value
|
||||
|
||||
|
||||
class ModelSelector:
|
||||
"""
|
||||
Selects appropriate AI models based on user tier and context.
|
||||
|
||||
This class implements tier-based routing to ensure:
|
||||
- Free users get Llama-3 (no cost)
|
||||
- Basic users get Claude Haiku (low cost)
|
||||
- Premium users get Claude Sonnet (medium cost)
|
||||
- Elite users get Claude Opus (high cost)
|
||||
|
||||
Context-specific optimizations adjust token limits and temperature
|
||||
for different use cases.
|
||||
"""
|
||||
|
||||
# Tier to model mapping
|
||||
TIER_MODELS = {
|
||||
UserTier.FREE: ModelType.LLAMA_3_8B,
|
||||
UserTier.BASIC: ModelType.CLAUDE_HAIKU,
|
||||
UserTier.PREMIUM: ModelType.CLAUDE_SONNET,
|
||||
UserTier.ELITE: ModelType.CLAUDE_SONNET_4,
|
||||
}
|
||||
|
||||
# Base token limits by tier
|
||||
BASE_TOKEN_LIMITS = {
|
||||
UserTier.FREE: 256,
|
||||
UserTier.BASIC: 512,
|
||||
UserTier.PREMIUM: 1024,
|
||||
UserTier.ELITE: 2048,
|
||||
}
|
||||
|
||||
# Temperature settings by context type
|
||||
CONTEXT_TEMPERATURES = {
|
||||
ContextType.STORY_PROGRESSION: 0.9, # Creative, varied
|
||||
ContextType.COMBAT_NARRATION: 0.8, # Exciting but coherent
|
||||
ContextType.QUEST_SELECTION: 0.5, # More deterministic
|
||||
ContextType.NPC_DIALOGUE: 0.85, # Natural conversation
|
||||
ContextType.SIMPLE_RESPONSE: 0.7, # Balanced
|
||||
}
|
||||
|
||||
# Token multipliers by context (relative to base)
|
||||
CONTEXT_TOKEN_MULTIPLIERS = {
|
||||
ContextType.STORY_PROGRESSION: 1.0, # Full allocation
|
||||
ContextType.COMBAT_NARRATION: 0.75, # Shorter, punchier
|
||||
ContextType.QUEST_SELECTION: 0.5, # Brief selection
|
||||
ContextType.NPC_DIALOGUE: 0.75, # Conversational
|
||||
ContextType.SIMPLE_RESPONSE: 0.5, # Quick responses
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the model selector."""
|
||||
logger.info("ModelSelector initialized")
|
||||
|
||||
def select_model(
|
||||
self,
|
||||
user_tier: UserTier,
|
||||
context_type: ContextType = ContextType.SIMPLE_RESPONSE
|
||||
) -> ModelConfig:
|
||||
"""
|
||||
Select the appropriate model configuration for a user and context.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier.
|
||||
context_type: The type of content being generated.
|
||||
|
||||
Returns:
|
||||
ModelConfig with model type, token limit, and temperature.
|
||||
|
||||
Example:
|
||||
>>> selector = ModelSelector()
|
||||
>>> config = selector.select_model(UserTier.PREMIUM, ContextType.STORY_PROGRESSION)
|
||||
>>> config.model_type
|
||||
<ModelType.CLAUDE_SONNET: 'anthropic/claude-3.5-sonnet'>
|
||||
"""
|
||||
# Get model for tier
|
||||
model_type = self.TIER_MODELS[user_tier]
|
||||
|
||||
# Calculate max tokens
|
||||
base_tokens = self.BASE_TOKEN_LIMITS[user_tier]
|
||||
multiplier = self.CONTEXT_TOKEN_MULTIPLIERS.get(context_type, 1.0)
|
||||
max_tokens = int(base_tokens * multiplier)
|
||||
|
||||
# Get temperature for context
|
||||
temperature = self.CONTEXT_TEMPERATURES.get(context_type, 0.7)
|
||||
|
||||
config = ModelConfig(
|
||||
model_type=model_type,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Model selected",
|
||||
user_tier=user_tier.value,
|
||||
context_type=context_type.value,
|
||||
model=model_type.value,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
def get_model_for_tier(self, user_tier: UserTier) -> ModelType:
|
||||
"""
|
||||
Get the default model for a user tier.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier.
|
||||
|
||||
Returns:
|
||||
The ModelType for this tier.
|
||||
"""
|
||||
return self.TIER_MODELS[user_tier]
|
||||
|
||||
def get_tier_info(self, user_tier: UserTier) -> dict:
|
||||
"""
|
||||
Get information about a tier's AI capabilities.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier.
|
||||
|
||||
Returns:
|
||||
Dictionary with tier information.
|
||||
"""
|
||||
model_type = self.TIER_MODELS[user_tier]
|
||||
|
||||
# Map models to friendly names
|
||||
model_names = {
|
||||
ModelType.LLAMA_3_8B: "Llama 3 8B",
|
||||
ModelType.CLAUDE_HAIKU: "Claude 3 Haiku",
|
||||
ModelType.CLAUDE_SONNET: "Claude 3.5 Sonnet",
|
||||
ModelType.CLAUDE_SONNET_4: "Claude Sonnet 4",
|
||||
}
|
||||
|
||||
# Model quality descriptions
|
||||
quality_descriptions = {
|
||||
ModelType.LLAMA_3_8B: "Good quality, optimized for speed",
|
||||
ModelType.CLAUDE_HAIKU: "High quality, fast responses",
|
||||
ModelType.CLAUDE_SONNET: "Excellent quality, detailed narratives",
|
||||
ModelType.CLAUDE_SONNET_4: "Best quality, most creative and nuanced",
|
||||
}
|
||||
|
||||
return {
|
||||
"tier": user_tier.value,
|
||||
"model": model_type.value,
|
||||
"model_name": model_names.get(model_type, model_type.value),
|
||||
"base_tokens": self.BASE_TOKEN_LIMITS[user_tier],
|
||||
"quality": quality_descriptions.get(model_type, "Standard quality"),
|
||||
}
|
||||
|
||||
def estimate_cost_per_request(self, user_tier: UserTier) -> float:
|
||||
"""
|
||||
Estimate the cost per AI request for a tier.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier.
|
||||
|
||||
Returns:
|
||||
Estimated cost in USD per request.
|
||||
|
||||
Note:
|
||||
These are rough estimates based on typical usage.
|
||||
Actual costs depend on input/output tokens.
|
||||
"""
|
||||
# Approximate cost per 1K tokens (input + output average)
|
||||
COST_PER_1K_TOKENS = {
|
||||
ModelType.LLAMA_3_8B: 0.0, # Free tier
|
||||
ModelType.CLAUDE_HAIKU: 0.001, # $0.25/1M input, $1.25/1M output
|
||||
ModelType.CLAUDE_SONNET: 0.006, # $3/1M input, $15/1M output
|
||||
ModelType.CLAUDE_SONNET_4: 0.015, # Claude Sonnet 4 pricing
|
||||
}
|
||||
|
||||
model_type = self.TIER_MODELS[user_tier]
|
||||
base_tokens = self.BASE_TOKEN_LIMITS[user_tier]
|
||||
cost_per_1k = COST_PER_1K_TOKENS.get(model_type, 0.0)
|
||||
|
||||
# Estimate: base tokens for output + ~50% for input tokens
|
||||
estimated_tokens = base_tokens * 1.5
|
||||
|
||||
return (estimated_tokens / 1000) * cost_per_1k
|
||||
540
api/app/ai/narrative_generator.py
Normal file
540
api/app/ai/narrative_generator.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
Narrative generator wrapper for AI content generation.
|
||||
|
||||
This module provides a high-level API for generating narrative content
|
||||
using the appropriate AI models based on user tier and context.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.ai.replicate_client import (
|
||||
ReplicateClient,
|
||||
ReplicateResponse,
|
||||
ReplicateClientError,
|
||||
)
|
||||
from app.ai.model_selector import (
|
||||
ModelSelector,
|
||||
ModelConfig,
|
||||
UserTier,
|
||||
ContextType,
|
||||
)
|
||||
from app.ai.prompt_templates import (
|
||||
PromptTemplates,
|
||||
PromptTemplateError,
|
||||
get_prompt_templates,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NarrativeResponse:
|
||||
"""Response from narrative generation."""
|
||||
narrative: str
|
||||
tokens_used: int
|
||||
tokens_input: int
|
||||
tokens_output: int
|
||||
model: str
|
||||
context_type: str
|
||||
generation_time: float
|
||||
|
||||
|
||||
class NarrativeGeneratorError(Exception):
|
||||
"""Base exception for narrative generator errors."""
|
||||
pass
|
||||
|
||||
|
||||
class NarrativeGenerator:
|
||||
"""
|
||||
High-level wrapper for AI narrative generation.
|
||||
|
||||
This class coordinates between the model selector, prompt templates,
|
||||
and AI clients to generate narrative content for the game.
|
||||
|
||||
It provides specialized methods for different narrative contexts:
|
||||
- Story progression responses
|
||||
- Combat narration
|
||||
- Quest selection
|
||||
- NPC dialogue
|
||||
"""
|
||||
|
||||
# System prompts for different contexts
|
||||
SYSTEM_PROMPTS = {
|
||||
ContextType.STORY_PROGRESSION: (
|
||||
"You are an expert Dungeon Master running a solo D&D-style adventure. "
|
||||
"Create immersive, engaging narratives that respond to player actions. "
|
||||
"Be descriptive but concise. Always end with a clear opportunity for the player to act. "
|
||||
"CRITICAL: NEVER give the player items, gold, equipment, or any rewards unless the action "
|
||||
"instructions explicitly state they should receive them. Only narrate what the template "
|
||||
"describes - do not improvise rewards or discoveries."
|
||||
),
|
||||
ContextType.COMBAT_NARRATION: (
|
||||
"You are a combat narrator for a fantasy RPG. "
|
||||
"Describe actions with visceral, cinematic detail. "
|
||||
"Keep narration punchy and exciting. Never include game mechanics in prose."
|
||||
),
|
||||
ContextType.QUEST_SELECTION: (
|
||||
"You are a quest selection system. "
|
||||
"Analyze the context and select the most narratively appropriate quest. "
|
||||
"Respond only with the quest_id - no explanation."
|
||||
),
|
||||
ContextType.NPC_DIALOGUE: (
|
||||
"You are a skilled voice actor portraying NPCs in a fantasy world. "
|
||||
"Stay in character at all times. Give each NPC a distinct voice and personality. "
|
||||
"Provide useful information while maintaining immersion."
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_selector: ModelSelector | None = None,
|
||||
replicate_client: ReplicateClient | None = None,
|
||||
prompt_templates: PromptTemplates | None = None
|
||||
):
|
||||
"""
|
||||
Initialize the narrative generator.
|
||||
|
||||
Args:
|
||||
model_selector: Optional custom model selector.
|
||||
replicate_client: Optional custom Replicate client.
|
||||
prompt_templates: Optional custom prompt templates.
|
||||
"""
|
||||
self.model_selector = model_selector or ModelSelector()
|
||||
self.replicate_client = replicate_client
|
||||
self.prompt_templates = prompt_templates or get_prompt_templates()
|
||||
|
||||
logger.info("NarrativeGenerator initialized")
|
||||
|
||||
def _get_client(self, model_config: ModelConfig) -> ReplicateClient:
|
||||
"""
|
||||
Get or create a Replicate client for the given model configuration.
|
||||
|
||||
Args:
|
||||
model_config: The model configuration to use.
|
||||
|
||||
Returns:
|
||||
ReplicateClient configured for the specified model.
|
||||
"""
|
||||
# If a client was provided at init, use it
|
||||
if self.replicate_client:
|
||||
return self.replicate_client
|
||||
|
||||
# Otherwise create a new client with the specified model
|
||||
return ReplicateClient(model=model_config.model_type)
|
||||
|
||||
def generate_story_response(
|
||||
self,
|
||||
character: dict[str, Any],
|
||||
action: str,
|
||||
game_state: dict[str, Any],
|
||||
user_tier: UserTier,
|
||||
conversation_history: list[dict[str, Any]] | None = None,
|
||||
world_context: str | None = None,
|
||||
action_instructions: str | None = None
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate a DM response to a player's story action.
|
||||
|
||||
Args:
|
||||
character: Character data dictionary with name, level, player_class, stats, etc.
|
||||
action: The action the player wants to take.
|
||||
game_state: Current game state with location, quests, etc.
|
||||
user_tier: The user's subscription tier.
|
||||
conversation_history: Optional list of recent conversation entries.
|
||||
world_context: Optional additional world information.
|
||||
action_instructions: Optional action-specific instructions for the AI from
|
||||
the dm_prompt_template field in action_prompts.yaml.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with the generated narrative and metadata.
|
||||
|
||||
Raises:
|
||||
NarrativeGeneratorError: If generation fails.
|
||||
|
||||
Example:
|
||||
>>> generator = NarrativeGenerator()
|
||||
>>> response = generator.generate_story_response(
|
||||
... character={"name": "Aldric", "level": 3, "player_class": "Fighter", ...},
|
||||
... action="I search the room for hidden doors",
|
||||
... game_state={"current_location": "Ancient Library", ...},
|
||||
... user_tier=UserTier.PREMIUM
|
||||
... )
|
||||
>>> print(response.narrative)
|
||||
"""
|
||||
context_type = ContextType.STORY_PROGRESSION
|
||||
|
||||
logger.info(
|
||||
"Generating story response",
|
||||
character_name=character.get("name"),
|
||||
action=action[:50],
|
||||
user_tier=user_tier.value,
|
||||
location=game_state.get("current_location")
|
||||
)
|
||||
|
||||
# Get model configuration for this tier and context
|
||||
model_config = self.model_selector.select_model(user_tier, context_type)
|
||||
|
||||
# Build the prompt from template
|
||||
try:
|
||||
prompt = self.prompt_templates.render(
|
||||
"story_action.j2",
|
||||
character=character,
|
||||
action=action,
|
||||
game_state=game_state,
|
||||
conversation_history=conversation_history or [],
|
||||
world_context=world_context,
|
||||
max_tokens=model_config.max_tokens,
|
||||
action_instructions=action_instructions
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
logger.error("Failed to render story prompt", error=str(e))
|
||||
raise NarrativeGeneratorError(f"Prompt template error: {e}")
|
||||
|
||||
# Debug: Log the full prompt being sent
|
||||
logger.debug(
|
||||
"Full prompt being sent to AI",
|
||||
prompt_length=len(prompt),
|
||||
conversation_history_count=len(conversation_history) if conversation_history else 0,
|
||||
prompt_preview=prompt[:500] + "..." if len(prompt) > 500 else prompt
|
||||
)
|
||||
# For detailed debugging, uncomment the line below:
|
||||
print(f"\n{'='*60}\nFULL PROMPT:\n{'='*60}\n{prompt}\n{'='*60}\n")
|
||||
|
||||
# Get system prompt
|
||||
system_prompt = self.SYSTEM_PROMPTS[context_type]
|
||||
|
||||
# Generate response
|
||||
try:
|
||||
client = self._get_client(model_config)
|
||||
response = client.generate(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=model_config.max_tokens,
|
||||
temperature=model_config.temperature,
|
||||
model=model_config.model_type
|
||||
)
|
||||
except ReplicateClientError as e:
|
||||
logger.error(
|
||||
"AI generation failed",
|
||||
error=str(e),
|
||||
context_type=context_type.value
|
||||
)
|
||||
raise NarrativeGeneratorError(f"AI generation failed: {e}")
|
||||
|
||||
logger.info(
|
||||
"Story response generated",
|
||||
tokens_used=response.tokens_used,
|
||||
model=response.model,
|
||||
generation_time=f"{response.generation_time:.2f}s"
|
||||
)
|
||||
|
||||
return NarrativeResponse(
|
||||
narrative=response.text,
|
||||
tokens_used=response.tokens_used,
|
||||
tokens_input=response.tokens_input,
|
||||
tokens_output=response.tokens_output,
|
||||
model=response.model,
|
||||
context_type=context_type.value,
|
||||
generation_time=response.generation_time
|
||||
)
|
||||
|
||||
def generate_combat_narration(
|
||||
self,
|
||||
character: dict[str, Any],
|
||||
combat_state: dict[str, Any],
|
||||
action: str,
|
||||
action_result: dict[str, Any],
|
||||
user_tier: UserTier,
|
||||
is_critical: bool = False,
|
||||
is_finishing_blow: bool = False
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate narration for a combat action.
|
||||
|
||||
Args:
|
||||
character: Character data dictionary.
|
||||
combat_state: Current combat state with enemies, round number, etc.
|
||||
action: Description of the combat action taken.
|
||||
action_result: Result of the action (hit, damage, effects, etc.).
|
||||
user_tier: The user's subscription tier.
|
||||
is_critical: Whether this was a critical hit/miss.
|
||||
is_finishing_blow: Whether this defeats the enemy.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with combat narration.
|
||||
|
||||
Raises:
|
||||
NarrativeGeneratorError: If generation fails.
|
||||
|
||||
Example:
|
||||
>>> response = generator.generate_combat_narration(
|
||||
... character={"name": "Aldric", ...},
|
||||
... combat_state={"round_number": 3, "enemies": [...], ...},
|
||||
... action="swings their sword at the goblin",
|
||||
... action_result={"hit": True, "damage": 12, ...},
|
||||
... user_tier=UserTier.BASIC
|
||||
... )
|
||||
"""
|
||||
context_type = ContextType.COMBAT_NARRATION
|
||||
|
||||
logger.info(
|
||||
"Generating combat narration",
|
||||
character_name=character.get("name"),
|
||||
action=action[:50],
|
||||
is_critical=is_critical,
|
||||
is_finishing_blow=is_finishing_blow
|
||||
)
|
||||
|
||||
# Get model configuration
|
||||
model_config = self.model_selector.select_model(user_tier, context_type)
|
||||
|
||||
# Build the prompt
|
||||
try:
|
||||
prompt = self.prompt_templates.render(
|
||||
"combat_action.j2",
|
||||
character=character,
|
||||
combat_state=combat_state,
|
||||
action=action,
|
||||
action_result=action_result,
|
||||
is_critical=is_critical,
|
||||
is_finishing_blow=is_finishing_blow,
|
||||
max_tokens=model_config.max_tokens
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
logger.error("Failed to render combat prompt", error=str(e))
|
||||
raise NarrativeGeneratorError(f"Prompt template error: {e}")
|
||||
|
||||
# Generate response
|
||||
system_prompt = self.SYSTEM_PROMPTS[context_type]
|
||||
|
||||
try:
|
||||
client = self._get_client(model_config)
|
||||
response = client.generate(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=model_config.max_tokens,
|
||||
temperature=model_config.temperature,
|
||||
model=model_config.model_type
|
||||
)
|
||||
except ReplicateClientError as e:
|
||||
logger.error("Combat narration generation failed", error=str(e))
|
||||
raise NarrativeGeneratorError(f"AI generation failed: {e}")
|
||||
|
||||
logger.info(
|
||||
"Combat narration generated",
|
||||
tokens_used=response.tokens_used,
|
||||
generation_time=f"{response.generation_time:.2f}s"
|
||||
)
|
||||
|
||||
return NarrativeResponse(
|
||||
narrative=response.text,
|
||||
tokens_used=response.tokens_used,
|
||||
tokens_input=response.tokens_input,
|
||||
tokens_output=response.tokens_output,
|
||||
model=response.model,
|
||||
context_type=context_type.value,
|
||||
generation_time=response.generation_time
|
||||
)
|
||||
|
||||
def generate_quest_selection(
|
||||
self,
|
||||
character: dict[str, Any],
|
||||
eligible_quests: list[dict[str, Any]],
|
||||
game_context: dict[str, Any],
|
||||
user_tier: UserTier,
|
||||
recent_actions: list[str] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Use AI to select the most contextually appropriate quest.
|
||||
|
||||
Args:
|
||||
character: Character data dictionary.
|
||||
eligible_quests: List of quest data dictionaries that can be offered.
|
||||
game_context: Current game context (location, events, etc.).
|
||||
user_tier: The user's subscription tier.
|
||||
recent_actions: Optional list of recent player actions.
|
||||
|
||||
Returns:
|
||||
The quest_id of the selected quest.
|
||||
|
||||
Raises:
|
||||
NarrativeGeneratorError: If generation fails or response is invalid.
|
||||
|
||||
Example:
|
||||
>>> quest_id = generator.generate_quest_selection(
|
||||
... character={"name": "Aldric", "level": 3, ...},
|
||||
... eligible_quests=[{"quest_id": "goblin_cave", ...}, ...],
|
||||
... game_context={"current_location": "Tavern", ...},
|
||||
... user_tier=UserTier.FREE
|
||||
... )
|
||||
>>> print(quest_id) # "goblin_cave"
|
||||
"""
|
||||
context_type = ContextType.QUEST_SELECTION
|
||||
|
||||
logger.info(
|
||||
"Generating quest selection",
|
||||
character_name=character.get("name"),
|
||||
num_eligible_quests=len(eligible_quests),
|
||||
location=game_context.get("current_location")
|
||||
)
|
||||
|
||||
if not eligible_quests:
|
||||
raise NarrativeGeneratorError("No eligible quests provided")
|
||||
|
||||
# Get model configuration
|
||||
model_config = self.model_selector.select_model(user_tier, context_type)
|
||||
|
||||
# Build the prompt
|
||||
try:
|
||||
prompt = self.prompt_templates.render(
|
||||
"quest_offering.j2",
|
||||
character=character,
|
||||
eligible_quests=eligible_quests,
|
||||
game_context=game_context,
|
||||
recent_actions=recent_actions or []
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
logger.error("Failed to render quest selection prompt", error=str(e))
|
||||
raise NarrativeGeneratorError(f"Prompt template error: {e}")
|
||||
|
||||
# Generate response
|
||||
system_prompt = self.SYSTEM_PROMPTS[context_type]
|
||||
|
||||
try:
|
||||
client = self._get_client(model_config)
|
||||
response = client.generate(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=model_config.max_tokens,
|
||||
temperature=model_config.temperature,
|
||||
model=model_config.model_type
|
||||
)
|
||||
except ReplicateClientError as e:
|
||||
logger.error("Quest selection generation failed", error=str(e))
|
||||
raise NarrativeGeneratorError(f"AI generation failed: {e}")
|
||||
|
||||
# Parse the response to get quest_id
|
||||
quest_id = response.text.strip().lower()
|
||||
|
||||
# Validate the response is a valid quest_id
|
||||
valid_quest_ids = {q.get("quest_id", "").lower() for q in eligible_quests}
|
||||
if quest_id not in valid_quest_ids:
|
||||
logger.warning(
|
||||
"AI returned invalid quest_id, using first eligible quest",
|
||||
returned_id=quest_id,
|
||||
valid_ids=list(valid_quest_ids)
|
||||
)
|
||||
quest_id = eligible_quests[0].get("quest_id", "")
|
||||
|
||||
logger.info(
|
||||
"Quest selected",
|
||||
quest_id=quest_id,
|
||||
tokens_used=response.tokens_used,
|
||||
generation_time=f"{response.generation_time:.2f}s"
|
||||
)
|
||||
|
||||
return quest_id
|
||||
|
||||
def generate_npc_dialogue(
|
||||
self,
|
||||
character: dict[str, Any],
|
||||
npc: dict[str, Any],
|
||||
conversation_topic: str,
|
||||
game_state: dict[str, Any],
|
||||
user_tier: UserTier,
|
||||
npc_relationship: str | None = None,
|
||||
previous_dialogue: list[dict[str, Any]] | None = None,
|
||||
npc_knowledge: list[str] | None = None
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate NPC dialogue in response to player conversation.
|
||||
|
||||
Args:
|
||||
character: Character data dictionary.
|
||||
npc: NPC data with name, role, personality, etc.
|
||||
conversation_topic: What the player said or wants to discuss.
|
||||
game_state: Current game state.
|
||||
user_tier: The user's subscription tier.
|
||||
npc_relationship: Optional description of relationship with NPC.
|
||||
previous_dialogue: Optional list of previous exchanges.
|
||||
npc_knowledge: Optional list of things this NPC knows about.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with NPC dialogue.
|
||||
|
||||
Raises:
|
||||
NarrativeGeneratorError: If generation fails.
|
||||
|
||||
Example:
|
||||
>>> response = generator.generate_npc_dialogue(
|
||||
... character={"name": "Aldric", ...},
|
||||
... npc={"name": "Old Barkeep", "role": "Tavern Owner", ...},
|
||||
... conversation_topic="What rumors have you heard lately?",
|
||||
... game_state={"current_location": "The Rusty Anchor", ...},
|
||||
... user_tier=UserTier.PREMIUM
|
||||
... )
|
||||
"""
|
||||
context_type = ContextType.NPC_DIALOGUE
|
||||
|
||||
logger.info(
|
||||
"Generating NPC dialogue",
|
||||
character_name=character.get("name"),
|
||||
npc_name=npc.get("name"),
|
||||
topic=conversation_topic[:50]
|
||||
)
|
||||
|
||||
# Get model configuration
|
||||
model_config = self.model_selector.select_model(user_tier, context_type)
|
||||
|
||||
# Build the prompt
|
||||
try:
|
||||
prompt = self.prompt_templates.render(
|
||||
"npc_dialogue.j2",
|
||||
character=character,
|
||||
npc=npc,
|
||||
conversation_topic=conversation_topic,
|
||||
game_state=game_state,
|
||||
npc_relationship=npc_relationship,
|
||||
previous_dialogue=previous_dialogue or [],
|
||||
npc_knowledge=npc_knowledge or [],
|
||||
max_tokens=model_config.max_tokens
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
logger.error("Failed to render NPC dialogue prompt", error=str(e))
|
||||
raise NarrativeGeneratorError(f"Prompt template error: {e}")
|
||||
|
||||
# Generate response
|
||||
system_prompt = self.SYSTEM_PROMPTS[context_type]
|
||||
|
||||
try:
|
||||
client = self._get_client(model_config)
|
||||
response = client.generate(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=model_config.max_tokens,
|
||||
temperature=model_config.temperature,
|
||||
model=model_config.model_type
|
||||
)
|
||||
except ReplicateClientError as e:
|
||||
logger.error("NPC dialogue generation failed", error=str(e))
|
||||
raise NarrativeGeneratorError(f"AI generation failed: {e}")
|
||||
|
||||
logger.info(
|
||||
"NPC dialogue generated",
|
||||
npc_name=npc.get("name"),
|
||||
tokens_used=response.tokens_used,
|
||||
generation_time=f"{response.generation_time:.2f}s"
|
||||
)
|
||||
|
||||
return NarrativeResponse(
|
||||
narrative=response.text,
|
||||
tokens_used=response.tokens_used,
|
||||
tokens_input=response.tokens_input,
|
||||
tokens_output=response.tokens_output,
|
||||
model=response.model,
|
||||
context_type=context_type.value,
|
||||
generation_time=response.generation_time
|
||||
)
|
||||
318
api/app/ai/prompt_templates.py
Normal file
318
api/app/ai/prompt_templates.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Jinja2 prompt template system for AI generation.
|
||||
|
||||
This module provides a templating system for building AI prompts
|
||||
with consistent structure and context injection.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PromptTemplateError(Exception):
|
||||
"""Error in prompt template processing."""
|
||||
pass
|
||||
|
||||
|
||||
class PromptTemplates:
|
||||
"""
|
||||
Manages Jinja2 templates for AI prompt generation.
|
||||
|
||||
Provides caching, helper functions, and consistent template rendering
|
||||
for all AI prompt types.
|
||||
"""
|
||||
|
||||
# Template directory relative to this module
|
||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
def __init__(self, template_dir: Path | str | None = None):
|
||||
"""
|
||||
Initialize the prompt template system.
|
||||
|
||||
Args:
|
||||
template_dir: Optional custom template directory path.
|
||||
"""
|
||||
self.template_dir = Path(template_dir) if template_dir else self.TEMPLATE_DIR
|
||||
|
||||
# Ensure template directory exists
|
||||
if not self.template_dir.exists():
|
||||
self.template_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.warning(
|
||||
"Template directory created",
|
||||
path=str(self.template_dir)
|
||||
)
|
||||
|
||||
# Set up Jinja2 environment with caching
|
||||
self.env = Environment(
|
||||
loader=FileSystemLoader(str(self.template_dir)),
|
||||
autoescape=select_autoescape(['html', 'xml']),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
# Register custom filters
|
||||
self._register_filters()
|
||||
|
||||
# Register custom globals
|
||||
self._register_globals()
|
||||
|
||||
logger.info(
|
||||
"PromptTemplates initialized",
|
||||
template_dir=str(self.template_dir)
|
||||
)
|
||||
|
||||
def _register_filters(self):
|
||||
"""Register custom Jinja2 filters."""
|
||||
self.env.filters['format_inventory'] = self._format_inventory
|
||||
self.env.filters['format_stats'] = self._format_stats
|
||||
self.env.filters['format_skills'] = self._format_skills
|
||||
self.env.filters['format_effects'] = self._format_effects
|
||||
self.env.filters['truncate_text'] = self._truncate_text
|
||||
self.env.filters['format_gold'] = self._format_gold
|
||||
|
||||
def _register_globals(self):
|
||||
"""Register global functions available in templates."""
|
||||
self.env.globals['len'] = len
|
||||
self.env.globals['min'] = min
|
||||
self.env.globals['max'] = max
|
||||
self.env.globals['enumerate'] = enumerate
|
||||
|
||||
# Custom filters
|
||||
@staticmethod
|
||||
def _format_inventory(items: list[dict], max_items: int = 10) -> str:
|
||||
"""
|
||||
Format inventory items for prompt context.
|
||||
|
||||
Args:
|
||||
items: List of item dictionaries with 'name' and 'quantity'.
|
||||
max_items: Maximum number of items to display.
|
||||
|
||||
Returns:
|
||||
Formatted inventory string.
|
||||
"""
|
||||
if not items:
|
||||
return "Empty inventory"
|
||||
|
||||
formatted = []
|
||||
for item in items[:max_items]:
|
||||
name = item.get('name', 'Unknown')
|
||||
qty = item.get('quantity', 1)
|
||||
if qty > 1:
|
||||
formatted.append(f"{name} (x{qty})")
|
||||
else:
|
||||
formatted.append(name)
|
||||
|
||||
result = ", ".join(formatted)
|
||||
if len(items) > max_items:
|
||||
result += f", and {len(items) - max_items} more items"
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _format_stats(stats: dict) -> str:
|
||||
"""
|
||||
Format character stats for prompt context.
|
||||
|
||||
Args:
|
||||
stats: Dictionary of stat names to values.
|
||||
|
||||
Returns:
|
||||
Formatted stats string.
|
||||
"""
|
||||
if not stats:
|
||||
return "No stats available"
|
||||
|
||||
formatted = []
|
||||
for stat, value in stats.items():
|
||||
# Convert snake_case to Title Case
|
||||
display_name = stat.replace('_', ' ').title()
|
||||
formatted.append(f"{display_name}: {value}")
|
||||
|
||||
return ", ".join(formatted)
|
||||
|
||||
@staticmethod
|
||||
def _format_skills(skills: list[dict], max_skills: int = 5) -> str:
|
||||
"""
|
||||
Format character skills for prompt context.
|
||||
|
||||
Args:
|
||||
skills: List of skill dictionaries with 'name' and 'level'.
|
||||
max_skills: Maximum number of skills to display.
|
||||
|
||||
Returns:
|
||||
Formatted skills string.
|
||||
"""
|
||||
if not skills:
|
||||
return "No skills"
|
||||
|
||||
formatted = []
|
||||
for skill in skills[:max_skills]:
|
||||
name = skill.get('name', 'Unknown')
|
||||
level = skill.get('level', 1)
|
||||
formatted.append(f"{name} (Lv.{level})")
|
||||
|
||||
result = ", ".join(formatted)
|
||||
if len(skills) > max_skills:
|
||||
result += f", and {len(skills) - max_skills} more skills"
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _format_effects(effects: list[dict]) -> str:
|
||||
"""
|
||||
Format active effects/buffs/debuffs for prompt context.
|
||||
|
||||
Args:
|
||||
effects: List of effect dictionaries.
|
||||
|
||||
Returns:
|
||||
Formatted effects string.
|
||||
"""
|
||||
if not effects:
|
||||
return "No active effects"
|
||||
|
||||
formatted = []
|
||||
for effect in effects:
|
||||
name = effect.get('name', 'Unknown')
|
||||
duration = effect.get('remaining_turns')
|
||||
if duration:
|
||||
formatted.append(f"{name} ({duration} turns)")
|
||||
else:
|
||||
formatted.append(name)
|
||||
|
||||
return ", ".join(formatted)
|
||||
|
||||
@staticmethod
|
||||
def _truncate_text(text: str, max_length: int = 100) -> str:
|
||||
"""
|
||||
Truncate text to maximum length with ellipsis.
|
||||
|
||||
Args:
|
||||
text: Text to truncate.
|
||||
max_length: Maximum character length.
|
||||
|
||||
Returns:
|
||||
Truncated text with ellipsis if needed.
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length - 3] + "..."
|
||||
|
||||
@staticmethod
|
||||
def _format_gold(amount: int) -> str:
|
||||
"""
|
||||
Format gold amount with commas.
|
||||
|
||||
Args:
|
||||
amount: Gold amount.
|
||||
|
||||
Returns:
|
||||
Formatted gold string.
|
||||
"""
|
||||
return f"{amount:,} gold"
|
||||
|
||||
def render(self, template_name: str, **context: Any) -> str:
|
||||
"""
|
||||
Render a template with the given context.
|
||||
|
||||
Args:
|
||||
template_name: Name of the template file (e.g., 'story_action.j2').
|
||||
**context: Variables to pass to the template.
|
||||
|
||||
Returns:
|
||||
Rendered template string.
|
||||
|
||||
Raises:
|
||||
PromptTemplateError: If template not found or rendering fails.
|
||||
"""
|
||||
try:
|
||||
template = self.env.get_template(template_name)
|
||||
rendered = template.render(**context)
|
||||
|
||||
logger.debug(
|
||||
"Template rendered",
|
||||
template=template_name,
|
||||
context_keys=list(context.keys()),
|
||||
output_length=len(rendered)
|
||||
)
|
||||
|
||||
return rendered.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Template rendering failed",
|
||||
template=template_name,
|
||||
error=str(e)
|
||||
)
|
||||
raise PromptTemplateError(f"Failed to render {template_name}: {e}")
|
||||
|
||||
def render_string(self, template_string: str, **context: Any) -> str:
|
||||
"""
|
||||
Render a template string directly.
|
||||
|
||||
Args:
|
||||
template_string: Jinja2 template string.
|
||||
**context: Variables to pass to the template.
|
||||
|
||||
Returns:
|
||||
Rendered string.
|
||||
|
||||
Raises:
|
||||
PromptTemplateError: If rendering fails.
|
||||
"""
|
||||
try:
|
||||
template = self.env.from_string(template_string)
|
||||
rendered = template.render(**context)
|
||||
return rendered.strip()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"String template rendering failed",
|
||||
error=str(e)
|
||||
)
|
||||
raise PromptTemplateError(f"Failed to render template string: {e}")
|
||||
|
||||
def get_template_names(self) -> list[str]:
|
||||
"""
|
||||
Get list of available template names.
|
||||
|
||||
Returns:
|
||||
List of template file names.
|
||||
"""
|
||||
return self.env.list_templates(extensions=['j2'])
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_templates: PromptTemplates | None = None
|
||||
|
||||
|
||||
def get_prompt_templates() -> PromptTemplates:
|
||||
"""
|
||||
Get the global PromptTemplates instance.
|
||||
|
||||
Returns:
|
||||
Singleton PromptTemplates instance.
|
||||
"""
|
||||
global _templates
|
||||
if _templates is None:
|
||||
_templates = PromptTemplates()
|
||||
return _templates
|
||||
|
||||
|
||||
def render_prompt(template_name: str, **context: Any) -> str:
|
||||
"""
|
||||
Convenience function to render a prompt template.
|
||||
|
||||
Args:
|
||||
template_name: Name of the template file.
|
||||
**context: Variables to pass to the template.
|
||||
|
||||
Returns:
|
||||
Rendered template string.
|
||||
"""
|
||||
return get_prompt_templates().render(template_name, **context)
|
||||
450
api/app/ai/replicate_client.py
Normal file
450
api/app/ai/replicate_client.py
Normal file
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
Replicate API client for AI model integration.
|
||||
|
||||
This module provides a client for interacting with the Replicate API
|
||||
to generate text using various models including Llama-3 and Claude models.
|
||||
All AI generation goes through Replicate for unified billing and management.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import replicate
|
||||
import structlog
|
||||
|
||||
from app.config import get_config
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ModelType(str, Enum):
|
||||
"""Supported model types on Replicate."""
|
||||
# Free tier - Llama models
|
||||
LLAMA_3_8B = "meta/meta-llama-3-8b-instruct"
|
||||
|
||||
# Paid tiers - Claude models via Replicate
|
||||
CLAUDE_HAIKU = "anthropic/claude-3.5-haiku"
|
||||
CLAUDE_SONNET = "anthropic/claude-3.5-sonnet"
|
||||
CLAUDE_SONNET_4 = "anthropic/claude-4.5-sonnet"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplicateResponse:
|
||||
"""Response from Replicate API generation."""
|
||||
text: str
|
||||
tokens_used: int # Deprecated: use tokens_output instead
|
||||
tokens_input: int
|
||||
tokens_output: int
|
||||
model: str
|
||||
generation_time: float
|
||||
|
||||
|
||||
class ReplicateClientError(Exception):
|
||||
"""Base exception for Replicate client errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ReplicateAPIError(ReplicateClientError):
|
||||
"""Error from Replicate API."""
|
||||
pass
|
||||
|
||||
|
||||
class ReplicateRateLimitError(ReplicateClientError):
|
||||
"""Rate limit exceeded on Replicate API."""
|
||||
pass
|
||||
|
||||
|
||||
class ReplicateTimeoutError(ReplicateClientError):
|
||||
"""Timeout waiting for Replicate response."""
|
||||
pass
|
||||
|
||||
|
||||
class ReplicateClient:
|
||||
"""
|
||||
Client for interacting with Replicate API.
|
||||
|
||||
Supports multiple models including Llama-3 and Claude models.
|
||||
Implements retry logic with exponential backoff for rate limits.
|
||||
"""
|
||||
|
||||
# Default model for free tier
|
||||
DEFAULT_MODEL = ModelType.LLAMA_3_8B
|
||||
|
||||
# Retry configuration
|
||||
MAX_RETRIES = 3
|
||||
INITIAL_RETRY_DELAY = 1.0 # seconds
|
||||
|
||||
# Default generation parameters
|
||||
DEFAULT_MAX_TOKENS = 256
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
DEFAULT_TOP_P = 0.9
|
||||
DEFAULT_TIMEOUT = 30 # seconds
|
||||
|
||||
# Model-specific defaults
|
||||
MODEL_DEFAULTS = {
|
||||
ModelType.LLAMA_3_8B: {
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
ModelType.CLAUDE_HAIKU: {
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.8,
|
||||
},
|
||||
ModelType.CLAUDE_SONNET: {
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.9,
|
||||
},
|
||||
ModelType.CLAUDE_SONNET_4: {
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.9,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, api_token: str | None = None, model: str | ModelType | None = None):
|
||||
"""
|
||||
Initialize the Replicate client.
|
||||
|
||||
Args:
|
||||
api_token: Replicate API token. If not provided, reads from config.
|
||||
model: Model identifier or ModelType enum. Defaults to Llama-3 8B Instruct.
|
||||
|
||||
Raises:
|
||||
ReplicateClientError: If API token is not configured.
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
# Get API token from parameter or config
|
||||
self.api_token = api_token or getattr(config, 'replicate_api_token', None)
|
||||
if not self.api_token:
|
||||
raise ReplicateClientError(
|
||||
"Replicate API token not configured. "
|
||||
"Set REPLICATE_API_TOKEN in environment or config."
|
||||
)
|
||||
|
||||
# Get model from parameter, config, or default
|
||||
if model is None:
|
||||
model = getattr(config, 'REPLICATE_MODEL', None) or self.DEFAULT_MODEL
|
||||
|
||||
# Convert string to ModelType if needed, or keep as string for custom models
|
||||
if isinstance(model, ModelType):
|
||||
self.model = model.value
|
||||
self.model_type = model
|
||||
elif isinstance(model, str):
|
||||
# Try to match to ModelType
|
||||
self.model = model
|
||||
self.model_type = self._get_model_type(model)
|
||||
else:
|
||||
self.model = self.DEFAULT_MODEL.value
|
||||
self.model_type = self.DEFAULT_MODEL
|
||||
|
||||
# Set the API token for the replicate library
|
||||
import os
|
||||
os.environ['REPLICATE_API_TOKEN'] = self.api_token
|
||||
|
||||
logger.info(
|
||||
"Replicate client initialized",
|
||||
model=self.model,
|
||||
model_type=self.model_type.name if self.model_type else "custom"
|
||||
)
|
||||
|
||||
def _get_model_type(self, model_string: str) -> ModelType | None:
|
||||
"""Get ModelType enum from model string."""
|
||||
for model_type in ModelType:
|
||||
if model_type.value == model_string:
|
||||
return model_type
|
||||
return None
|
||||
|
||||
def _is_claude_model(self) -> bool:
|
||||
"""Check if current model is a Claude model."""
|
||||
return self.model_type in [
|
||||
ModelType.CLAUDE_HAIKU,
|
||||
ModelType.CLAUDE_SONNET,
|
||||
ModelType.CLAUDE_SONNET_4
|
||||
]
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
timeout: int | None = None,
|
||||
model: str | ModelType | None = None
|
||||
) -> ReplicateResponse:
|
||||
"""
|
||||
Generate text using the configured model.
|
||||
|
||||
Args:
|
||||
prompt: The user prompt to send to the model.
|
||||
system_prompt: Optional system prompt for context setting.
|
||||
max_tokens: Maximum tokens to generate. Uses model defaults if not specified.
|
||||
temperature: Sampling temperature (0.0-1.0). Uses model defaults if not specified.
|
||||
top_p: Top-p sampling parameter. Defaults to 0.9.
|
||||
timeout: Timeout in seconds. Defaults to 30.
|
||||
model: Override the default model for this request.
|
||||
|
||||
Returns:
|
||||
ReplicateResponse with generated text and metadata.
|
||||
|
||||
Raises:
|
||||
ReplicateAPIError: For API errors.
|
||||
ReplicateRateLimitError: When rate limited.
|
||||
ReplicateTimeoutError: When request times out.
|
||||
"""
|
||||
# Handle model override
|
||||
if model:
|
||||
if isinstance(model, ModelType):
|
||||
current_model = model.value
|
||||
current_model_type = model
|
||||
else:
|
||||
current_model = model
|
||||
current_model_type = self._get_model_type(model)
|
||||
else:
|
||||
current_model = self.model
|
||||
current_model_type = self.model_type
|
||||
|
||||
# Get model-specific defaults
|
||||
model_defaults = self.MODEL_DEFAULTS.get(current_model_type, {})
|
||||
|
||||
# Apply defaults (parameter > model default > class default)
|
||||
max_tokens = max_tokens or model_defaults.get("max_tokens", self.DEFAULT_MAX_TOKENS)
|
||||
temperature = temperature or model_defaults.get("temperature", self.DEFAULT_TEMPERATURE)
|
||||
top_p = top_p or self.DEFAULT_TOP_P
|
||||
timeout = timeout or self.DEFAULT_TIMEOUT
|
||||
|
||||
# Format prompt based on model type
|
||||
is_claude = current_model_type in [
|
||||
ModelType.CLAUDE_HAIKU,
|
||||
ModelType.CLAUDE_SONNET,
|
||||
ModelType.CLAUDE_SONNET_4
|
||||
]
|
||||
|
||||
if is_claude:
|
||||
input_params = self._build_claude_params(
|
||||
prompt, system_prompt, max_tokens, temperature, top_p
|
||||
)
|
||||
else:
|
||||
# Llama-style formatting
|
||||
formatted_prompt = self._format_llama_prompt(prompt, system_prompt)
|
||||
input_params = {
|
||||
"prompt": formatted_prompt,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"Generating text with Replicate",
|
||||
model=current_model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
is_claude=is_claude
|
||||
)
|
||||
|
||||
# Execute with retry logic
|
||||
start_time = time.time()
|
||||
output = self._execute_with_retry(current_model, input_params, timeout)
|
||||
generation_time = time.time() - start_time
|
||||
|
||||
# Parse response
|
||||
text = self._parse_response(output)
|
||||
|
||||
# Estimate tokens (rough approximation: ~4 chars per token)
|
||||
# Calculate input tokens from the actual prompt sent
|
||||
prompt_text = input_params.get("prompt", "")
|
||||
system_text = input_params.get("system_prompt", "")
|
||||
total_input_text = prompt_text + system_text
|
||||
tokens_input = len(total_input_text) // 4
|
||||
|
||||
# Calculate output tokens from response
|
||||
tokens_output = len(text) // 4
|
||||
|
||||
# Total for backwards compatibility
|
||||
tokens_used = tokens_input + tokens_output
|
||||
|
||||
logger.info(
|
||||
"Replicate generation complete",
|
||||
model=current_model,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
tokens_used=tokens_used,
|
||||
generation_time=f"{generation_time:.2f}s",
|
||||
response_length=len(text)
|
||||
)
|
||||
|
||||
return ReplicateResponse(
|
||||
text=text.strip(),
|
||||
tokens_used=tokens_used,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
model=current_model,
|
||||
generation_time=generation_time
|
||||
)
|
||||
|
||||
def _build_claude_params(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str | None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
top_p: float
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build input parameters for Claude models on Replicate.
|
||||
|
||||
Args:
|
||||
prompt: User prompt.
|
||||
system_prompt: Optional system prompt.
|
||||
max_tokens: Maximum tokens to generate.
|
||||
temperature: Sampling temperature.
|
||||
top_p: Top-p sampling parameter.
|
||||
|
||||
Returns:
|
||||
Dictionary of input parameters for Replicate API.
|
||||
"""
|
||||
params = {
|
||||
"prompt": prompt,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
params["system_prompt"] = system_prompt
|
||||
|
||||
return params
|
||||
|
||||
def _format_llama_prompt(self, prompt: str, system_prompt: str | None = None) -> str:
|
||||
"""
|
||||
Format prompt for Llama-3 Instruct model.
|
||||
|
||||
Llama-3 Instruct uses a specific format with special tokens.
|
||||
|
||||
Args:
|
||||
prompt: User prompt.
|
||||
system_prompt: Optional system prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if system_prompt:
|
||||
parts.append(f"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n{system_prompt}<|eot_id|>")
|
||||
else:
|
||||
parts.append("<|begin_of_text|>")
|
||||
|
||||
parts.append(f"<|start_header_id|>user<|end_header_id|>\n\n{prompt}<|eot_id|>")
|
||||
parts.append("<|start_header_id|>assistant<|end_header_id|>\n\n")
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def _parse_response(self, output: Any) -> str:
|
||||
"""
|
||||
Parse response from Replicate API.
|
||||
|
||||
Handles both streaming iterators and direct string responses.
|
||||
|
||||
Args:
|
||||
output: Raw output from Replicate API.
|
||||
|
||||
Returns:
|
||||
Parsed text string.
|
||||
"""
|
||||
if hasattr(output, '__iter__') and not isinstance(output, str):
|
||||
return "".join(output)
|
||||
return str(output)
|
||||
|
||||
def _execute_with_retry(
|
||||
self,
|
||||
model: str,
|
||||
input_params: dict[str, Any],
|
||||
timeout: int
|
||||
) -> Any:
|
||||
"""
|
||||
Execute Replicate API call with retry logic.
|
||||
|
||||
Implements exponential backoff for rate limit errors.
|
||||
|
||||
Args:
|
||||
model: Model identifier to run.
|
||||
input_params: Input parameters for the model.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
API response output.
|
||||
|
||||
Raises:
|
||||
ReplicateAPIError: For API errors after retries.
|
||||
ReplicateRateLimitError: When rate limit persists after retries.
|
||||
ReplicateTimeoutError: When request times out.
|
||||
"""
|
||||
last_error = None
|
||||
retry_delay = self.INITIAL_RETRY_DELAY
|
||||
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
output = replicate.run(
|
||||
model,
|
||||
input=input_params
|
||||
)
|
||||
return output
|
||||
|
||||
except replicate.exceptions.ReplicateError as e:
|
||||
error_message = str(e).lower()
|
||||
|
||||
if "rate limit" in error_message or "429" in error_message:
|
||||
last_error = ReplicateRateLimitError(f"Rate limited: {e}")
|
||||
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
logger.warning(
|
||||
"Rate limited, retrying",
|
||||
attempt=attempt + 1,
|
||||
retry_delay=retry_delay
|
||||
)
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
continue
|
||||
else:
|
||||
raise last_error
|
||||
|
||||
elif "timeout" in error_message:
|
||||
raise ReplicateTimeoutError(f"Request timed out: {e}")
|
||||
|
||||
else:
|
||||
raise ReplicateAPIError(f"API error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e).lower()
|
||||
|
||||
if "timeout" in error_message:
|
||||
raise ReplicateTimeoutError(f"Request timed out: {e}")
|
||||
|
||||
raise ReplicateAPIError(f"Unexpected error: {e}")
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
raise ReplicateAPIError("Max retries exceeded")
|
||||
|
||||
def validate_api_key(self) -> bool:
|
||||
"""
|
||||
Validate that the API key is valid.
|
||||
|
||||
Makes a minimal API call to check credentials.
|
||||
|
||||
Returns:
|
||||
True if API key is valid, False otherwise.
|
||||
"""
|
||||
try:
|
||||
model_name = self.model.split(':')[0]
|
||||
model = replicate.models.get(model_name)
|
||||
return model is not None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"API key validation failed",
|
||||
error=str(e)
|
||||
)
|
||||
return False
|
||||
160
api/app/ai/response_parser.py
Normal file
160
api/app/ai/response_parser.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Response parser for AI narrative responses.
|
||||
|
||||
This module handles AI response parsing. Game state changes (items, gold, XP)
|
||||
are now handled exclusively through predetermined dice check outcomes in
|
||||
action templates, not through AI-generated JSON.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItemGrant:
|
||||
"""
|
||||
Represents an item granted by the AI during gameplay.
|
||||
|
||||
The AI can grant items in two ways:
|
||||
1. By item_id - References an existing item from game data
|
||||
2. By name/type/description - Creates a generic item
|
||||
"""
|
||||
item_id: Optional[str] = None # For existing items
|
||||
name: Optional[str] = None # For generic items
|
||||
item_type: Optional[str] = None # consumable, weapon, armor, quest_item
|
||||
description: Optional[str] = None
|
||||
value: int = 0
|
||||
quantity: int = 1
|
||||
|
||||
def is_existing_item(self) -> bool:
|
||||
"""Check if this references an existing item by ID."""
|
||||
return self.item_id is not None
|
||||
|
||||
def is_generic_item(self) -> bool:
|
||||
"""Check if this is a generic item created by the AI."""
|
||||
return self.item_id is None and self.name is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameStateChanges:
|
||||
"""
|
||||
Structured game state changes extracted from AI response.
|
||||
|
||||
These changes are validated and applied to the character after
|
||||
the AI generates its narrative response.
|
||||
"""
|
||||
items_given: list[ItemGrant] = field(default_factory=list)
|
||||
items_taken: list[str] = field(default_factory=list) # item_ids to remove
|
||||
gold_given: int = 0
|
||||
gold_taken: int = 0
|
||||
experience_given: int = 0
|
||||
quest_offered: Optional[str] = None # quest_id
|
||||
quest_completed: Optional[str] = None # quest_id
|
||||
location_change: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedAIResponse:
|
||||
"""
|
||||
Complete parsed AI response with narrative and game state changes.
|
||||
|
||||
Attributes:
|
||||
narrative: The narrative text to display to the player
|
||||
game_changes: Structured game state changes to apply
|
||||
raw_response: The original unparsed response from AI
|
||||
parse_success: Whether parsing succeeded
|
||||
parse_errors: Any errors encountered during parsing
|
||||
"""
|
||||
narrative: str
|
||||
game_changes: GameStateChanges
|
||||
raw_response: str
|
||||
parse_success: bool = True
|
||||
parse_errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ResponseParserError(Exception):
|
||||
"""Exception raised when response parsing fails critically."""
|
||||
pass
|
||||
|
||||
|
||||
def parse_ai_response(response_text: str) -> ParsedAIResponse:
|
||||
"""
|
||||
Parse an AI response to extract the narrative text.
|
||||
|
||||
Game state changes (items, gold, XP) are now handled exclusively through
|
||||
predetermined dice check outcomes, not through AI-generated structured data.
|
||||
|
||||
Args:
|
||||
response_text: The raw AI response text
|
||||
|
||||
Returns:
|
||||
ParsedAIResponse with narrative (game_changes will be empty)
|
||||
"""
|
||||
logger.debug("Parsing AI response", response_length=len(response_text))
|
||||
|
||||
# Return the full response as narrative
|
||||
# Game state changes come from predetermined check_outcomes, not AI
|
||||
return ParsedAIResponse(
|
||||
narrative=response_text.strip(),
|
||||
game_changes=GameStateChanges(),
|
||||
raw_response=response_text,
|
||||
parse_success=True,
|
||||
parse_errors=[]
|
||||
)
|
||||
|
||||
|
||||
def _parse_game_actions(data: dict[str, Any]) -> GameStateChanges:
|
||||
"""
|
||||
Parse the game actions dictionary into a GameStateChanges object.
|
||||
|
||||
Args:
|
||||
data: Dictionary from parsed JSON
|
||||
|
||||
Returns:
|
||||
GameStateChanges object with parsed data
|
||||
"""
|
||||
changes = GameStateChanges()
|
||||
|
||||
# Parse items_given
|
||||
if "items_given" in data and data["items_given"]:
|
||||
for item_data in data["items_given"]:
|
||||
if isinstance(item_data, dict):
|
||||
item_grant = ItemGrant(
|
||||
item_id=item_data.get("item_id"),
|
||||
name=item_data.get("name"),
|
||||
item_type=item_data.get("type"),
|
||||
description=item_data.get("description"),
|
||||
value=item_data.get("value", 0),
|
||||
quantity=item_data.get("quantity", 1)
|
||||
)
|
||||
changes.items_given.append(item_grant)
|
||||
elif isinstance(item_data, str):
|
||||
# Simple string format - treat as item_id
|
||||
changes.items_given.append(ItemGrant(item_id=item_data))
|
||||
|
||||
# Parse items_taken
|
||||
if "items_taken" in data and data["items_taken"]:
|
||||
changes.items_taken = [
|
||||
item_id for item_id in data["items_taken"]
|
||||
if isinstance(item_id, str)
|
||||
]
|
||||
|
||||
# Parse gold changes
|
||||
changes.gold_given = int(data.get("gold_given", 0))
|
||||
changes.gold_taken = int(data.get("gold_taken", 0))
|
||||
|
||||
# Parse experience
|
||||
changes.experience_given = int(data.get("experience_given", 0))
|
||||
|
||||
# Parse quest changes
|
||||
changes.quest_offered = data.get("quest_offered")
|
||||
changes.quest_completed = data.get("quest_completed")
|
||||
|
||||
# Parse location change
|
||||
changes.location_change = data.get("location_change")
|
||||
|
||||
return changes
|
||||
81
api/app/ai/templates/combat_action.j2
Normal file
81
api/app/ai/templates/combat_action.j2
Normal file
@@ -0,0 +1,81 @@
|
||||
{#
|
||||
Combat Action Prompt Template
|
||||
Used for narrating combat actions and outcomes.
|
||||
|
||||
Required context:
|
||||
- character: Character object
|
||||
- combat_state: Current combat information
|
||||
- action: The combat action being taken
|
||||
- action_result: Outcome of the action (damage, effects, etc.)
|
||||
|
||||
Optional context:
|
||||
- is_critical: Whether this was a critical hit/miss
|
||||
- is_finishing_blow: Whether this defeats the enemy
|
||||
#}
|
||||
You are the Dungeon Master narrating an exciting combat encounter.
|
||||
|
||||
## Combatants
|
||||
**{{ character.name }}** (Level {{ character.level }} {{ character.player_class }})
|
||||
- Health: {{ character.current_hp }}/{{ character.max_hp }} HP
|
||||
{% if character.effects %}
|
||||
- Active Effects: {{ character.effects | format_effects }}
|
||||
{% endif %}
|
||||
|
||||
**vs**
|
||||
|
||||
{% for enemy in combat_state.enemies %}
|
||||
**{{ enemy.name }}**
|
||||
- Health: {{ enemy.current_hp }}/{{ enemy.max_hp }} HP
|
||||
{% if enemy.effects %}
|
||||
- Status: {{ enemy.effects | format_effects }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
## Combat Round {{ combat_state.round_number }}
|
||||
Turn: {{ combat_state.current_turn }}
|
||||
|
||||
## Action Taken
|
||||
{{ character.name }} {{ action }}
|
||||
|
||||
## Action Result
|
||||
{% if action_result.hit %}
|
||||
- **Hit!** {{ action_result.damage }} damage dealt
|
||||
{% if is_critical %}
|
||||
- **CRITICAL HIT!**
|
||||
{% endif %}
|
||||
{% if action_result.effects_applied %}
|
||||
- Applied: {{ action_result.effects_applied | join(', ') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- **Miss!** The attack fails to connect
|
||||
{% endif %}
|
||||
|
||||
{% if is_finishing_blow %}
|
||||
**{{ action_result.target }} has been defeated!**
|
||||
{% endif %}
|
||||
|
||||
## Your Task
|
||||
Narrate this combat action:
|
||||
1. Describe the action with visceral, cinematic detail
|
||||
2. Show the result - the impact, the enemy's reaction
|
||||
{% if is_finishing_blow %}
|
||||
3. Describe the enemy's defeat dramatically
|
||||
{% else %}
|
||||
3. Hint at the enemy's remaining threat or weakness
|
||||
{% endif %}
|
||||
|
||||
{% if max_tokens %}
|
||||
**IMPORTANT: Your response must be under {{ (max_tokens * 0.6) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.**
|
||||
{% if max_tokens <= 150 %}
|
||||
Keep it to 2-3 punchy sentences.
|
||||
{% elif max_tokens <= 300 %}
|
||||
Keep it to 1 short paragraph.
|
||||
{% else %}
|
||||
Keep it to 1-2 exciting paragraphs.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
Keep it punchy and action-packed. Use active voice and dynamic verbs.
|
||||
Don't include game mechanics in the narrative - just the story.
|
||||
|
||||
Respond only with the narrative - no dice rolls or damage numbers.
|
||||
138
api/app/ai/templates/npc_dialogue.j2
Normal file
138
api/app/ai/templates/npc_dialogue.j2
Normal file
@@ -0,0 +1,138 @@
|
||||
{#
|
||||
NPC Dialogue Prompt Template - Enhanced with persistent NPC data.
|
||||
Used for generating contextual NPC conversations with rich personality.
|
||||
|
||||
Required context:
|
||||
- character: Player character information (name, level, player_class)
|
||||
- npc: NPC information with personality, appearance, dialogue_hooks
|
||||
- conversation_topic: What the player wants to discuss
|
||||
- game_state: Current game state
|
||||
|
||||
Optional context:
|
||||
- npc_knowledge: List of information the NPC can share
|
||||
- revealed_secrets: Secrets being revealed this conversation
|
||||
- interaction_count: Number of times player has talked to this NPC
|
||||
- relationship_level: 0-100 relationship score (50 is neutral)
|
||||
- previous_dialogue: Previous exchanges with this NPC
|
||||
#}
|
||||
You are roleplaying as an NPC in a fantasy world, having a conversation with a player character.
|
||||
|
||||
## The NPC
|
||||
**{{ npc.name }}** - {{ npc.role }}
|
||||
|
||||
{% if npc.appearance %}
|
||||
- **Appearance:** {{ npc.appearance if npc.appearance is string else npc.appearance.brief if npc.appearance.brief else npc.appearance }}
|
||||
{% endif %}
|
||||
|
||||
{% if npc.personality %}
|
||||
{% if npc.personality.traits %}
|
||||
- **Personality Traits:** {{ npc.personality.traits | join(', ') }}
|
||||
{% elif npc.personality is string %}
|
||||
- **Personality:** {{ npc.personality }}
|
||||
{% endif %}
|
||||
{% if npc.personality.speech_style %}
|
||||
- **Speaking Style:** {{ npc.personality.speech_style }}
|
||||
{% endif %}
|
||||
{% if npc.personality.quirks %}
|
||||
- **Quirks:** {{ npc.personality.quirks | join('; ') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if npc.dialogue_hooks and npc.dialogue_hooks.greeting %}
|
||||
- **Typical Greeting:** "{{ npc.dialogue_hooks.greeting }}"
|
||||
{% endif %}
|
||||
|
||||
{% if npc.goals %}
|
||||
- **Current Goals:** {{ npc.goals }}
|
||||
{% endif %}
|
||||
|
||||
## The Player Character
|
||||
**{{ character.name }}** - Level {{ character.level }} {{ character.player_class }}
|
||||
{% if interaction_count and interaction_count > 1 %}
|
||||
- **Familiarity:** This is conversation #{{ interaction_count }} - the NPC recognizes {{ character.name }}
|
||||
{% endif %}
|
||||
{% if relationship_level %}
|
||||
{% if relationship_level >= 80 %}
|
||||
- **Relationship:** Close friend ({{ relationship_level }}/100) - treats player warmly
|
||||
{% elif relationship_level >= 60 %}
|
||||
- **Relationship:** Friendly acquaintance ({{ relationship_level }}/100) - helpful and open
|
||||
{% elif relationship_level >= 40 %}
|
||||
- **Relationship:** Neutral ({{ relationship_level }}/100) - professional but guarded
|
||||
{% elif relationship_level >= 20 %}
|
||||
- **Relationship:** Distrustful ({{ relationship_level }}/100) - wary and curt
|
||||
{% else %}
|
||||
- **Relationship:** Hostile ({{ relationship_level }}/100) - dismissive or antagonistic
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
## Current Setting
|
||||
- **Location:** {{ game_state.current_location }}
|
||||
{% if game_state.time_of_day %}
|
||||
- **Time:** {{ game_state.time_of_day }}
|
||||
{% endif %}
|
||||
{% if game_state.active_quests %}
|
||||
- **Player's Active Quests:** {{ game_state.active_quests | length }}
|
||||
{% endif %}
|
||||
|
||||
{% if npc_knowledge %}
|
||||
## Knowledge the NPC May Share
|
||||
The NPC knows about the following (share naturally as relevant to conversation):
|
||||
{% for info in npc_knowledge %}
|
||||
- {{ info }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if revealed_secrets %}
|
||||
## IMPORTANT: Secrets to Reveal This Conversation
|
||||
Based on the player's relationship with this NPC, naturally reveal the following:
|
||||
{% for secret in revealed_secrets %}
|
||||
- {{ secret }}
|
||||
{% endfor %}
|
||||
Work these into the dialogue naturally - don't dump all information at once.
|
||||
Make it feel earned, like the NPC is opening up to someone they trust.
|
||||
{% endif %}
|
||||
|
||||
{% if npc.relationships %}
|
||||
## NPC Relationships (for context)
|
||||
{% for rel in npc.relationships %}
|
||||
- Feels {{ rel.attitude }} toward {{ rel.npc_id }}{% if rel.reason %} ({{ rel.reason }}){% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if previous_dialogue %}
|
||||
## Previous Conversation
|
||||
{% for exchange in previous_dialogue[-2:] %}
|
||||
- **{{ character.name }}:** {{ exchange.player_line | truncate_text(100) }}
|
||||
- **{{ npc.name }}:** {{ exchange.npc_response | truncate_text(100) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
## Player Says
|
||||
"{{ conversation_topic }}"
|
||||
|
||||
## Your Task
|
||||
Respond as {{ npc.name }} in character. Generate dialogue that:
|
||||
1. **Matches the NPC's personality and speech style exactly** - use their quirks, accent, and manner
|
||||
2. **Acknowledges the relationship** - be warmer to friends, cooler to strangers
|
||||
3. **Shares relevant knowledge naturally** - don't info-dump, weave it into conversation
|
||||
4. **Reveals secrets if specified** - make it feel like earned trust, not random exposition
|
||||
5. **Feels alive and memorable** - give this NPC a distinct voice
|
||||
|
||||
{% if max_tokens %}
|
||||
**IMPORTANT: Your response must be under {{ (max_tokens * 0.6) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.**
|
||||
{% if max_tokens <= 150 %}
|
||||
Keep it to 1-2 sentences of dialogue.
|
||||
{% elif max_tokens <= 300 %}
|
||||
Keep it to 2-3 sentences of dialogue.
|
||||
{% else %}
|
||||
Keep it to 2-4 sentences of dialogue.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Keep the response to 2-4 sentences of dialogue.
|
||||
{% endif %}
|
||||
|
||||
You may include brief action/emotion tags in *asterisks* to show gestures and expressions.
|
||||
|
||||
Respond only as the NPC - no narration or out-of-character text.
|
||||
Format: *action/emotion* "Dialogue goes here."
|
||||
61
api/app/ai/templates/quest_offering.j2
Normal file
61
api/app/ai/templates/quest_offering.j2
Normal file
@@ -0,0 +1,61 @@
|
||||
{#
|
||||
Quest Offering Prompt Template
|
||||
Used for AI to select the most contextually appropriate quest.
|
||||
|
||||
Required context:
|
||||
- eligible_quests: List of quest objects that can be offered
|
||||
- game_context: Current game state information
|
||||
- character: Character information
|
||||
|
||||
Optional context:
|
||||
- recent_actions: Recent player actions
|
||||
#}
|
||||
You are selecting the most appropriate quest to offer to a player based on their current context.
|
||||
|
||||
## Player Character
|
||||
**{{ character.name }}** - Level {{ character.level }} {{ character.player_class }}
|
||||
{% if character.completed_quests %}
|
||||
- Completed Quests: {{ character.completed_quests | length }}
|
||||
{% endif %}
|
||||
|
||||
## Current Context
|
||||
- **Location:** {{ game_context.current_location }} ({{ game_context.location_type }})
|
||||
{% if recent_actions %}
|
||||
- **Recent Actions:**
|
||||
{% for action in recent_actions[-3:] %}
|
||||
- {{ action }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if game_context.active_quests %}
|
||||
- **Active Quests:** {{ game_context.active_quests | length }} in progress
|
||||
{% endif %}
|
||||
{% if game_context.world_events %}
|
||||
- **Current Events:** {{ game_context.world_events | join(', ') }}
|
||||
{% endif %}
|
||||
|
||||
## Available Quests
|
||||
{% for quest in eligible_quests %}
|
||||
### {{ loop.index }}. {{ quest.name }}
|
||||
- **Quest ID:** {{ quest.quest_id }}
|
||||
- **Difficulty:** {{ quest.difficulty }}
|
||||
- **Quest Giver:** {{ quest.quest_giver }}
|
||||
- **Description:** {{ quest.description | truncate_text(200) }}
|
||||
- **Narrative Hooks:**
|
||||
{% for hook in quest.narrative_hooks %}
|
||||
- {{ hook }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
## Your Task
|
||||
Select the ONE quest that best fits the current narrative context.
|
||||
|
||||
Consider:
|
||||
1. Which quest's narrative hooks connect best to the player's recent actions?
|
||||
2. Which quest giver makes sense for this location?
|
||||
3. Which difficulty is appropriate for the character's level and situation?
|
||||
4. Which quest would feel most natural to discover right now?
|
||||
|
||||
Respond with ONLY the quest_id of your selection on a single line.
|
||||
Example response: quest_goblin_cave
|
||||
|
||||
Do not include any explanation - just the quest_id.
|
||||
112
api/app/ai/templates/story_action.j2
Normal file
112
api/app/ai/templates/story_action.j2
Normal file
@@ -0,0 +1,112 @@
|
||||
{#
|
||||
Story Action Prompt Template
|
||||
Used for generating DM responses to player story actions.
|
||||
|
||||
Required context:
|
||||
- character: Character object with name, level, player_class, stats
|
||||
- game_state: GameState with current_location, location_type, active_quests
|
||||
- action: String describing the player's action
|
||||
- conversation_history: List of recent conversation entries (optional)
|
||||
|
||||
Optional context:
|
||||
- custom_topic: For specific queries
|
||||
- world_context: Additional world information
|
||||
#}
|
||||
You are the Dungeon Master for {{ character.name }}, a level {{ character.level }} {{ character.player_class }}.
|
||||
|
||||
## Character Status
|
||||
- **Health:** {{ character.current_hp }}/{{ character.max_hp }} HP
|
||||
- **Stats:** {{ character.stats | format_stats }}
|
||||
{% if character.skills %}
|
||||
- **Skills:** {{ character.skills | format_skills }}
|
||||
{% endif %}
|
||||
{% if character.effects %}
|
||||
- **Active Effects:** {{ character.effects | format_effects }}
|
||||
{% endif %}
|
||||
|
||||
## Current Situation
|
||||
- **Location:** {{ game_state.current_location }} ({{ game_state.location_type }})
|
||||
{% if game_state.discovered_locations %}
|
||||
- **Known Locations:** {{ game_state.discovered_locations | join(', ') }}
|
||||
{% endif %}
|
||||
{% if game_state.active_quests %}
|
||||
- **Active Quests:** {{ game_state.active_quests | length }} quest(s) in progress
|
||||
{% endif %}
|
||||
{% if game_state.time_of_day %}
|
||||
- **Time:** {{ game_state.time_of_day }}
|
||||
{% endif %}
|
||||
|
||||
{% if location %}
|
||||
## Location Details
|
||||
- **Place:** {{ location.name }}
|
||||
- **Type:** {{ location.type if location.type else game_state.location_type }}
|
||||
{% if location.description %}
|
||||
- **Description:** {{ location.description | truncate_text(300) }}
|
||||
{% endif %}
|
||||
{% if location.ambient %}
|
||||
- **Atmosphere:** {{ location.ambient | truncate_text(200) }}
|
||||
{% endif %}
|
||||
{% if location.lore %}
|
||||
- **Lore:** {{ location.lore | truncate_text(150) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if npcs_present %}
|
||||
## NPCs Present
|
||||
{% for npc in npcs_present %}
|
||||
- **{{ npc.name }}** ({{ npc.role }}): {{ npc.appearance if npc.appearance is string else npc.appearance.brief if npc.appearance else 'No description' }}
|
||||
{% endfor %}
|
||||
These NPCs are available for conversation. Include them naturally in the scene if relevant.
|
||||
{% endif %}
|
||||
|
||||
{% if conversation_history %}
|
||||
## Recent History
|
||||
{% for entry in conversation_history[-3:] %}
|
||||
- **Turn {{ entry.turn }}:** {{ entry.action }}
|
||||
> {{ entry.dm_response }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
## Player Action
|
||||
{{ action }}
|
||||
|
||||
{% if action_instructions %}
|
||||
## Action-Specific Instructions
|
||||
{{ action_instructions }}
|
||||
{% endif %}
|
||||
|
||||
## Your Task
|
||||
Generate a narrative response that:
|
||||
1. Acknowledges the player's action and describes their attempt
|
||||
2. Describes what happens as a result, including any discoveries or consequences
|
||||
3. Sets up the next decision point or opportunity for action
|
||||
|
||||
{% if max_tokens %}
|
||||
**IMPORTANT: Your response must be under {{ (max_tokens * 0.7) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.**
|
||||
{% if max_tokens <= 150 %}
|
||||
Keep it to 1 short paragraph (2-3 sentences).
|
||||
{% elif max_tokens <= 300 %}
|
||||
Keep it to 1 paragraph (4-5 sentences).
|
||||
{% elif max_tokens <= 600 %}
|
||||
Keep it to 1-2 paragraphs.
|
||||
{% else %}
|
||||
Keep it to 2-3 paragraphs.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
Keep the tone immersive and engaging. Use vivid descriptions but stay concise.
|
||||
If the action involves NPCs, give them personality and realistic reactions.
|
||||
If the action could fail or succeed, describe the outcome based on the character's abilities.
|
||||
|
||||
**CRITICAL RULES - Player Agency:**
|
||||
- NEVER make decisions for the player (no auto-purchasing, no automatic commitments)
|
||||
- NEVER complete transactions without explicit player consent
|
||||
- NEVER take items or spend gold without the player choosing to do so
|
||||
- Present options, choices, or discoveries - then let the player decide
|
||||
- End with clear options or a question about what they want to do next
|
||||
- If items/services have costs, always state prices and ask if they want to proceed
|
||||
|
||||
{% if world_context %}
|
||||
## World Context
|
||||
{{ world_context }}
|
||||
{% endif %}
|
||||
0
api/app/api/__init__.py
Normal file
0
api/app/api/__init__.py
Normal file
529
api/app/api/auth.py
Normal file
529
api/app/api/auth.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Authentication API Blueprint
|
||||
|
||||
This module provides API endpoints for user authentication and management:
|
||||
- User registration
|
||||
- User login/logout
|
||||
- Email verification
|
||||
- Password reset
|
||||
|
||||
All endpoints follow the standard API response format defined in app.utils.response.
|
||||
"""
|
||||
|
||||
import re
|
||||
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
error_response,
|
||||
unauthorized_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user, extract_session_token
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_email(email: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate email address format.
|
||||
|
||||
Args:
|
||||
email: Email address to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not email:
|
||||
return False, "Email is required"
|
||||
|
||||
config = get_config()
|
||||
max_length = config.auth.email_max_length
|
||||
|
||||
if len(email) > max_length:
|
||||
return False, f"Email must be no more than {max_length} characters"
|
||||
|
||||
# Email regex pattern
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(pattern, email):
|
||||
return False, "Invalid email format"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_password(password: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate password strength.
|
||||
|
||||
Args:
|
||||
password: Password to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not password:
|
||||
return False, "Password is required"
|
||||
|
||||
config = get_config()
|
||||
|
||||
min_length = config.auth.password_min_length
|
||||
if len(password) < min_length:
|
||||
return False, f"Password must be at least {min_length} characters long"
|
||||
|
||||
if len(password) > 128:
|
||||
return False, "Password must be no more than 128 characters"
|
||||
|
||||
errors = []
|
||||
|
||||
if config.auth.password_require_uppercase and not re.search(r'[A-Z]', password):
|
||||
errors.append("at least one uppercase letter")
|
||||
|
||||
if config.auth.password_require_lowercase and not re.search(r'[a-z]', password):
|
||||
errors.append("at least one lowercase letter")
|
||||
|
||||
if config.auth.password_require_number and not re.search(r'[0-9]', password):
|
||||
errors.append("at least one number")
|
||||
|
||||
if config.auth.password_require_special and not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password):
|
||||
errors.append("at least one special character")
|
||||
|
||||
if errors:
|
||||
return False, f"Password must contain {', '.join(errors)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_name(name: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate user name.
|
||||
|
||||
Args:
|
||||
name: Name to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not name:
|
||||
return False, "Name is required"
|
||||
|
||||
config = get_config()
|
||||
|
||||
min_length = config.auth.name_min_length
|
||||
max_length = config.auth.name_max_length
|
||||
|
||||
if len(name) < min_length:
|
||||
return False, f"Name must be at least {min_length} characters"
|
||||
|
||||
if len(name) > max_length:
|
||||
return False, f"Name must be no more than {max_length} characters"
|
||||
|
||||
# Allow letters, spaces, hyphens, apostrophes
|
||||
if not re.match(r"^[a-zA-Z\s\-']+$", name):
|
||||
return False, "Name can only contain letters, spaces, hyphens, and apostrophes"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@auth_bp.route('/api/v1/auth/register', methods=['POST'])
|
||||
def api_register():
|
||||
"""
|
||||
Register a new user account.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "Player Name"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: User created successfully
|
||||
400: Validation error or email already exists
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
password = data.get('password', '')
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
email_valid, email_error = validate_email(email)
|
||||
if not email_valid:
|
||||
validation_errors['email'] = email_error
|
||||
|
||||
password_valid, password_error = validate_password(password)
|
||||
if not password_valid:
|
||||
validation_errors['password'] = password_error
|
||||
|
||||
name_valid, name_error = validate_name(name)
|
||||
if not name_valid:
|
||||
validation_errors['name'] = name_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(validation_errors)
|
||||
|
||||
# Register user
|
||||
appwrite = AppwriteService()
|
||||
user_data = appwrite.register_user(email=email, password=password, name=name)
|
||||
|
||||
logger.info("User registered successfully", user_id=user_data.id, email=email)
|
||||
|
||||
return created_response(
|
||||
result={
|
||||
"user": user_data.to_dict(),
|
||||
"message": "Registration successful. Please check your email to verify your account."
|
||||
}
|
||||
)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Registration failed", error=str(e), code=e.code)
|
||||
|
||||
# Check for specific error codes
|
||||
if e.code == 409: # Conflict - user already exists
|
||||
return validation_error_response({"email": "An account with this email already exists"})
|
||||
|
||||
return error_response(message="Registration failed. Please try again.", code="REGISTRATION_ERROR")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during registration", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/login', methods=['POST'])
|
||||
def api_login():
|
||||
"""
|
||||
Authenticate a user and create a session.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"remember_me": false
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Login successful, session cookie set
|
||||
401: Invalid credentials
|
||||
400: Validation error
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
password = data.get('password', '')
|
||||
remember_me = data.get('remember_me', False)
|
||||
|
||||
# Validate inputs
|
||||
if not email:
|
||||
return validation_error_response({"email": "Email is required"})
|
||||
|
||||
if not password:
|
||||
return validation_error_response({"password": "Password is required"})
|
||||
|
||||
# Authenticate user
|
||||
appwrite = AppwriteService()
|
||||
session_data, user_data = appwrite.login_user(email=email, password=password)
|
||||
|
||||
logger.info("User logged in successfully", user_id=user_data.id, email=email)
|
||||
|
||||
# Set session cookie
|
||||
config = get_config()
|
||||
duration = config.auth.duration_remember_me if remember_me else config.auth.duration_normal
|
||||
|
||||
response = make_response(success_response(
|
||||
result={
|
||||
"user": user_data.to_dict(),
|
||||
"message": "Login successful"
|
||||
}
|
||||
))
|
||||
|
||||
response.set_cookie(
|
||||
key=config.auth.cookie_name,
|
||||
value=session_data.session_id,
|
||||
max_age=duration,
|
||||
httponly=config.auth.http_only,
|
||||
secure=config.auth.secure,
|
||||
samesite=config.auth.same_site,
|
||||
path=config.auth.path
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.warning("Login failed", email=email if 'email' in locals() else 'unknown', error=str(e), code=e.code)
|
||||
|
||||
# Generic error message for security (don't reveal if email exists)
|
||||
return unauthorized_response(message="Invalid email or password")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during login", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/logout', methods=['POST'])
|
||||
@require_auth
|
||||
def api_logout():
|
||||
"""
|
||||
Log out the current user by deleting their session.
|
||||
|
||||
Returns:
|
||||
200: Logout successful, session cookie cleared
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get session token
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
return unauthorized_response(message="No active session")
|
||||
|
||||
# Logout user
|
||||
appwrite = AppwriteService()
|
||||
appwrite.logout_user(session_id=token)
|
||||
|
||||
user = get_current_user()
|
||||
logger.info("User logged out successfully", user_id=user.id if user else 'unknown')
|
||||
|
||||
# Clear session cookie
|
||||
config = get_config()
|
||||
|
||||
response = make_response(success_response(
|
||||
result={"message": "Logout successful"}
|
||||
))
|
||||
|
||||
response.set_cookie(
|
||||
key=config.auth.cookie_name,
|
||||
value='',
|
||||
max_age=0,
|
||||
httponly=config.auth.http_only,
|
||||
secure=config.auth.secure,
|
||||
samesite=config.auth.same_site,
|
||||
path=config.auth.path
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Logout failed", error=str(e), code=e.code)
|
||||
return error_response(message="Logout failed", code="LOGOUT_ERROR")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during logout", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/verify-email', methods=['GET'])
|
||||
def api_verify_email():
|
||||
"""
|
||||
Verify a user's email address.
|
||||
|
||||
Query Parameters:
|
||||
userId: User ID from verification link
|
||||
secret: Verification secret from verification link
|
||||
|
||||
Returns:
|
||||
Redirects to login page with success/error message
|
||||
"""
|
||||
try:
|
||||
user_id = request.args.get('userId')
|
||||
secret = request.args.get('secret')
|
||||
|
||||
if not user_id or not secret:
|
||||
flash("Invalid verification link", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
# Verify email
|
||||
appwrite = AppwriteService()
|
||||
appwrite.verify_email(user_id=user_id, secret=secret)
|
||||
|
||||
logger.info("Email verified successfully", user_id=user_id)
|
||||
|
||||
flash("Email verified successfully! You can now log in.", "success")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Email verification failed", error=str(e), code=e.code)
|
||||
flash("Email verification failed. The link may be invalid or expired.", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during email verification", error=str(e))
|
||||
flash("An unexpected error occurred", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/forgot-password', methods=['POST'])
|
||||
def api_forgot_password():
|
||||
"""
|
||||
Request a password reset email.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Always returns success (for security, don't reveal if email exists)
|
||||
400: Validation error
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
# Validate email
|
||||
email_valid, email_error = validate_email(email)
|
||||
if not email_valid:
|
||||
return validation_error_response({"email": email_error})
|
||||
|
||||
# Request password reset
|
||||
appwrite = AppwriteService()
|
||||
appwrite.request_password_reset(email=email)
|
||||
|
||||
logger.info("Password reset requested", email=email)
|
||||
|
||||
# Always return success for security
|
||||
return success_response(
|
||||
result={
|
||||
"message": "If an account exists with this email, you will receive a password reset link shortly."
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during password reset request", error=str(e))
|
||||
# Still return success for security
|
||||
return success_response(
|
||||
result={
|
||||
"message": "If an account exists with this email, you will receive a password reset link shortly."
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/reset-password', methods=['POST'])
|
||||
def api_reset_password():
|
||||
"""
|
||||
Confirm password reset and update password.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"user_id": "user_id_from_link",
|
||||
"secret": "secret_from_link",
|
||||
"password": "NewSecurePass123!"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Password reset successful
|
||||
400: Validation error or invalid/expired link
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
user_id = data.get('user_id', '').strip()
|
||||
secret = data.get('secret', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
if not user_id:
|
||||
validation_errors['user_id'] = "User ID is required"
|
||||
|
||||
if not secret:
|
||||
validation_errors['secret'] = "Reset secret is required"
|
||||
|
||||
password_valid, password_error = validate_password(password)
|
||||
if not password_valid:
|
||||
validation_errors['password'] = password_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(validation_errors)
|
||||
|
||||
# Confirm password reset
|
||||
appwrite = AppwriteService()
|
||||
appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password)
|
||||
|
||||
logger.info("Password reset successfully", user_id=user_id)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Password reset successful. You can now log in with your new password."
|
||||
}
|
||||
)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Password reset failed", error=str(e), code=e.code)
|
||||
return error_response(
|
||||
message="Password reset failed. The link may be invalid or expired.",
|
||||
code="PASSWORD_RESET_ERROR"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during password reset", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
# ===== TEMPLATE ROUTES (for rendering HTML pages) =====
|
||||
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
"""Render the login page."""
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET'])
|
||||
def register_page():
|
||||
"""Render the registration page."""
|
||||
return render_template('auth/register.html')
|
||||
|
||||
|
||||
@auth_bp.route('/forgot-password', methods=['GET'])
|
||||
def forgot_password_page():
|
||||
"""Render the forgot password page."""
|
||||
return render_template('auth/forgot_password.html')
|
||||
|
||||
|
||||
@auth_bp.route('/reset-password', methods=['GET'])
|
||||
def reset_password_page():
|
||||
"""Render the reset password page."""
|
||||
user_id = request.args.get('userId', '')
|
||||
secret = request.args.get('secret', '')
|
||||
|
||||
return render_template('auth/reset_password.html', user_id=user_id, secret=secret)
|
||||
898
api/app/api/characters.py
Normal file
898
api/app/api/characters.py
Normal file
@@ -0,0 +1,898 @@
|
||||
"""
|
||||
Character API Blueprint
|
||||
|
||||
This module provides API endpoints for character management:
|
||||
- List user's characters
|
||||
- Get character details
|
||||
- Create new character
|
||||
- Delete character
|
||||
- Unlock skills
|
||||
- Respec skills
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.character_service import (
|
||||
get_character_service,
|
||||
CharacterLimitExceeded,
|
||||
CharacterNotFound,
|
||||
SkillUnlockError,
|
||||
InsufficientGold
|
||||
)
|
||||
from app.services.class_loader import get_class_loader
|
||||
from app.services.origin_service import get_origin_service
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
characters_bp = Blueprint('characters', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_character_name(name: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate character name.
|
||||
|
||||
Args:
|
||||
name: Character name to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not name:
|
||||
return False, "Character name is required"
|
||||
|
||||
if len(name) < 2:
|
||||
return False, "Character name must be at least 2 characters"
|
||||
|
||||
if len(name) > 50:
|
||||
return False, "Character name must be no more than 50 characters"
|
||||
|
||||
# Allow letters, spaces, hyphens, apostrophes, and common fantasy characters
|
||||
if not all(c.isalnum() or c in " -'" for c in name):
|
||||
return False, "Character name can only contain letters, numbers, spaces, hyphens, and apostrophes"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_class_id(class_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate class ID.
|
||||
|
||||
Args:
|
||||
class_id: Class ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not class_id:
|
||||
return False, "Class ID is required"
|
||||
|
||||
valid_classes = [
|
||||
'vanguard', 'assassin', 'arcanist', 'luminary',
|
||||
'wildstrider', 'oathkeeper', 'necromancer', 'lorekeeper'
|
||||
]
|
||||
|
||||
if class_id not in valid_classes:
|
||||
return False, f"Invalid class ID. Must be one of: {', '.join(valid_classes)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_origin_id(origin_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate origin ID.
|
||||
|
||||
Args:
|
||||
origin_id: Origin ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not origin_id:
|
||||
return False, "Origin ID is required"
|
||||
|
||||
valid_origins = [
|
||||
'soul_revenant', 'memory_thief', 'shadow_apprentice', 'escaped_captive'
|
||||
]
|
||||
|
||||
if origin_id not in valid_origins:
|
||||
return False, f"Invalid origin ID. Must be one of: {', '.join(valid_origins)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_skill_id(skill_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate skill ID.
|
||||
|
||||
Args:
|
||||
skill_id: Skill ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not skill_id:
|
||||
return False, "Skill ID is required"
|
||||
|
||||
if len(skill_id) > 100:
|
||||
return False, "Skill ID is too long"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@characters_bp.route('/api/v1/characters', methods=['GET'])
|
||||
@require_auth
|
||||
def list_characters():
|
||||
"""
|
||||
List all characters owned by the current user.
|
||||
|
||||
Returns:
|
||||
200: List of characters
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"characters": [
|
||||
{
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": "warrior",
|
||||
"level": 5,
|
||||
"gold": 1000
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"tier": "free",
|
||||
"limit": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Listing characters", user_id=user.id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get user's characters
|
||||
characters = char_service.get_user_characters(user.id)
|
||||
|
||||
# Get tier information
|
||||
tier = user.tier
|
||||
from app.services.character_service import CHARACTER_LIMITS
|
||||
limit = CHARACTER_LIMITS.get(tier, 1)
|
||||
|
||||
# Convert characters to dict format
|
||||
character_list = [
|
||||
{
|
||||
"character_id": char.character_id,
|
||||
"name": char.name,
|
||||
"class": char.player_class.class_id,
|
||||
"class_name": char.player_class.name,
|
||||
"level": char.level,
|
||||
"experience": char.experience,
|
||||
"gold": char.gold,
|
||||
"current_location": char.current_location,
|
||||
"origin": char.origin.id
|
||||
}
|
||||
for char in characters
|
||||
]
|
||||
|
||||
logger.info("Characters listed successfully",
|
||||
user_id=user.id,
|
||||
count=len(characters))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"characters": character_list,
|
||||
"count": len(characters),
|
||||
"tier": tier,
|
||||
"limit": limit
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list characters",
|
||||
user_id=user.id if user else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_LIST_ERROR",
|
||||
message="Failed to retrieve characters",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_character(character_id: str):
|
||||
"""
|
||||
Get detailed information about a specific character.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Character details
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": {...},
|
||||
"origin": {...},
|
||||
"level": 5,
|
||||
"experience": 250,
|
||||
"base_stats": {...},
|
||||
"unlocked_skills": [...],
|
||||
"inventory": [...],
|
||||
"equipped": {...},
|
||||
"gold": 1000
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Getting character",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get character (ownership validated in service)
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
logger.info("Character retrieved successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
return success_response(result=character.to_dict())
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get character",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_GET_ERROR",
|
||||
message="Failed to retrieve character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters', methods=['POST'])
|
||||
@require_auth
|
||||
def create_character():
|
||||
"""
|
||||
Create a new character for the current user.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"name": "Thorin Ironheart",
|
||||
"class_id": "warrior",
|
||||
"origin_id": "soul_revenant"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: Character created successfully
|
||||
400: Validation error or character limit exceeded
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": "warrior",
|
||||
"level": 1,
|
||||
"message": "Character created successfully"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
class_id = data.get('class_id', '').strip().lower()
|
||||
origin_id = data.get('origin_id', '').strip().lower()
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
name_valid, name_error = validate_character_name(name)
|
||||
if not name_valid:
|
||||
validation_errors['name'] = name_error
|
||||
|
||||
class_valid, class_error = validate_class_id(class_id)
|
||||
if not class_valid:
|
||||
validation_errors['class_id'] = class_error
|
||||
|
||||
origin_valid, origin_error = validate_origin_id(origin_id)
|
||||
if not origin_valid:
|
||||
validation_errors['origin_id'] = origin_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details=validation_errors
|
||||
)
|
||||
|
||||
logger.info("Creating character",
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
class_id=class_id,
|
||||
origin_id=origin_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Create character
|
||||
character = char_service.create_character(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
class_id=class_id,
|
||||
origin_id=origin_id
|
||||
)
|
||||
|
||||
logger.info("Character created successfully",
|
||||
user_id=user.id,
|
||||
character_id=character.character_id,
|
||||
name=name)
|
||||
|
||||
return created_response(
|
||||
result={
|
||||
"character_id": character.character_id,
|
||||
"name": character.name,
|
||||
"class": character.player_class.class_id,
|
||||
"class_name": character.player_class.name,
|
||||
"origin": character.origin.id,
|
||||
"origin_name": character.origin.name,
|
||||
"level": character.level,
|
||||
"gold": character.gold,
|
||||
"current_location": character.current_location,
|
||||
"message": "Character created successfully"
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterLimitExceeded as e:
|
||||
logger.warning("Character limit exceeded",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_LIMIT_EXCEEDED",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid class or origin",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return validation_error_response(
|
||||
message=str(e),
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create character",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_CREATE_ERROR",
|
||||
message="Failed to create character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_character(character_id: str):
|
||||
"""
|
||||
Delete a character (soft delete - marks as inactive).
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Character deleted successfully
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Character deleted successfully",
|
||||
"character_id": "char_001"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Deleting character",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Delete character (ownership validated in service)
|
||||
char_service.delete_character(character_id, user.id)
|
||||
|
||||
logger.info("Character deleted successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Character deleted successfully",
|
||||
"character_id": character_id
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for deletion",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete character",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_DELETE_ERROR",
|
||||
message="Failed to delete character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>/skills/unlock', methods=['POST'])
|
||||
@require_auth
|
||||
def unlock_skill(character_id: str):
|
||||
"""
|
||||
Unlock a skill for a character.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"skill_id": "power_strike"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Skill unlocked successfully
|
||||
400: Validation error or unlock requirements not met
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Skill unlocked successfully",
|
||||
"character_id": "char_001",
|
||||
"skill_id": "power_strike",
|
||||
"unlocked_skills": ["power_strike"],
|
||||
"available_points": 0
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
skill_id = data.get('skill_id', '').strip()
|
||||
|
||||
# Validate skill_id
|
||||
skill_valid, skill_error = validate_skill_id(skill_id)
|
||||
if not skill_valid:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details={"skill_id": skill_error}
|
||||
)
|
||||
|
||||
logger.info("Unlocking skill",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Unlock skill (validates ownership, prerequisites, skill points)
|
||||
character = char_service.unlock_skill(character_id, user.id, skill_id)
|
||||
|
||||
# Calculate available skill points
|
||||
available_points = character.level - len(character.unlocked_skills)
|
||||
|
||||
logger.info("Skill unlocked successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id,
|
||||
available_points=available_points)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Skill unlocked successfully",
|
||||
"character_id": character_id,
|
||||
"skill_id": skill_id,
|
||||
"unlocked_skills": character.unlocked_skills,
|
||||
"available_points": available_points
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for skill unlock",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except SkillUnlockError as e:
|
||||
logger.warning("Skill unlock failed",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id if 'skill_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="SKILL_UNLOCK_ERROR",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to unlock skill",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="SKILL_UNLOCK_ERROR",
|
||||
message="Failed to unlock skill",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>/skills/respec', methods=['POST'])
|
||||
@require_auth
|
||||
def respec_skills(character_id: str):
|
||||
"""
|
||||
Reset all unlocked skills for a character.
|
||||
|
||||
Cost: level × 100 gold
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Skills reset successfully
|
||||
400: Insufficient gold
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Skills reset successfully",
|
||||
"character_id": "char_001",
|
||||
"cost": 500,
|
||||
"remaining_gold": 500,
|
||||
"available_points": 5
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Respecing character skills",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get character to calculate cost
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
respec_cost = character.level * 100
|
||||
|
||||
# Respec skills (validates ownership and gold)
|
||||
character = char_service.respec_skills(character_id, user.id)
|
||||
|
||||
# Calculate available skill points
|
||||
available_points = character.level - len(character.unlocked_skills)
|
||||
|
||||
logger.info("Skills respeced successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
cost=respec_cost,
|
||||
remaining_gold=character.gold,
|
||||
available_points=available_points)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Skills reset successfully",
|
||||
"character_id": character_id,
|
||||
"cost": respec_cost,
|
||||
"remaining_gold": character.gold,
|
||||
"available_points": available_points
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for respec",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except InsufficientGold as e:
|
||||
logger.warning("Insufficient gold for respec",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="INSUFFICIENT_GOLD",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to respec skills",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="RESPEC_ERROR",
|
||||
message="Failed to reset skills",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
# ===== CLASSES & ORIGINS ENDPOINTS (Reference Data) =====
|
||||
|
||||
@characters_bp.route('/api/v1/classes', methods=['GET'])
|
||||
def list_classes():
|
||||
"""
|
||||
List all available player classes.
|
||||
|
||||
This endpoint provides reference data for character creation.
|
||||
No authentication required.
|
||||
|
||||
Returns:
|
||||
200: List of all classes with basic info
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"classes": [
|
||||
{
|
||||
"class_id": "vanguard",
|
||||
"name": "Vanguard",
|
||||
"description": "Armored warrior...",
|
||||
"base_stats": {...},
|
||||
"skill_trees": ["Shield Bearer", "Weapon Master"]
|
||||
}
|
||||
],
|
||||
"count": 8
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Listing all classes")
|
||||
|
||||
# Get class loader
|
||||
class_loader = get_class_loader()
|
||||
|
||||
# Get all class IDs
|
||||
class_ids = class_loader.get_all_class_ids()
|
||||
|
||||
# Load all classes
|
||||
classes = []
|
||||
for class_id in class_ids:
|
||||
player_class = class_loader.load_class(class_id)
|
||||
if player_class:
|
||||
classes.append({
|
||||
"class_id": player_class.class_id,
|
||||
"name": player_class.name,
|
||||
"description": player_class.description,
|
||||
"base_stats": player_class.base_stats.to_dict(),
|
||||
"skill_trees": [tree.name for tree in player_class.skill_trees],
|
||||
"starting_equipment": player_class.starting_equipment,
|
||||
"starting_abilities": player_class.starting_abilities
|
||||
})
|
||||
|
||||
logger.info("Classes listed successfully", count=len(classes))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"classes": classes,
|
||||
"count": len(classes)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list classes", error=str(e))
|
||||
return error_response(
|
||||
code="CLASS_LIST_ERROR",
|
||||
message="Failed to retrieve classes",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/classes/<class_id>', methods=['GET'])
|
||||
def get_class(class_id: str):
|
||||
"""
|
||||
Get detailed information about a specific class.
|
||||
|
||||
This endpoint provides full class data including skill trees.
|
||||
No authentication required.
|
||||
|
||||
Args:
|
||||
class_id: Class ID (e.g., "vanguard", "assassin")
|
||||
|
||||
Returns:
|
||||
200: Full class details with skill trees
|
||||
404: Class not found
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"class_id": "vanguard",
|
||||
"name": "Vanguard",
|
||||
"description": "Armored warrior...",
|
||||
"base_stats": {...},
|
||||
"skill_trees": [
|
||||
{
|
||||
"tree_id": "shield_bearer",
|
||||
"name": "Shield Bearer",
|
||||
"nodes": [...]
|
||||
}
|
||||
],
|
||||
"starting_equipment": [...],
|
||||
"starting_abilities": [...]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Getting class details", class_id=class_id)
|
||||
|
||||
# Get class loader
|
||||
class_loader = get_class_loader()
|
||||
|
||||
# Load class
|
||||
player_class = class_loader.load_class(class_id)
|
||||
|
||||
if not player_class:
|
||||
logger.warning("Class not found", class_id=class_id)
|
||||
return not_found_response(message=f"Class not found: {class_id}")
|
||||
|
||||
logger.info("Class retrieved successfully", class_id=class_id)
|
||||
|
||||
# Return full class data
|
||||
return success_response(result=player_class.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get class",
|
||||
class_id=class_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CLASS_GET_ERROR",
|
||||
message="Failed to retrieve class",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/origins', methods=['GET'])
|
||||
def list_origins():
|
||||
"""
|
||||
List all available character origins.
|
||||
|
||||
This endpoint provides reference data for character creation.
|
||||
No authentication required.
|
||||
|
||||
Returns:
|
||||
200: List of all origins
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"origins": [
|
||||
{
|
||||
"id": "soul_revenant",
|
||||
"name": "Soul Revenant",
|
||||
"description": "Returned from death...",
|
||||
"starting_location": {...},
|
||||
"narrative_hooks": [...],
|
||||
"starting_bonus": {...}
|
||||
}
|
||||
],
|
||||
"count": 4
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Listing all origins")
|
||||
|
||||
# Get origin service
|
||||
origin_service = get_origin_service()
|
||||
|
||||
# Get all origin IDs
|
||||
origin_ids = origin_service.get_all_origin_ids()
|
||||
|
||||
# Load all origins
|
||||
origins = []
|
||||
for origin_id in origin_ids:
|
||||
origin = origin_service.load_origin(origin_id)
|
||||
if origin:
|
||||
origins.append(origin.to_dict())
|
||||
|
||||
logger.info("Origins listed successfully", count=len(origins))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"origins": origins,
|
||||
"count": len(origins)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list origins", error=str(e))
|
||||
return error_response(
|
||||
code="ORIGIN_LIST_ERROR",
|
||||
message="Failed to retrieve origins",
|
||||
status=500
|
||||
)
|
||||
302
api/app/api/game_mechanics.py
Normal file
302
api/app/api/game_mechanics.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Game Mechanics API Blueprint
|
||||
|
||||
This module provides API endpoints for game mechanics that determine
|
||||
outcomes before AI narration:
|
||||
- Skill checks (perception, persuasion, stealth, etc.)
|
||||
- Search/loot actions
|
||||
- Dice rolls
|
||||
|
||||
These endpoints return structured results that can be used for UI
|
||||
dice animations and then passed to AI for narrative description.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.outcome_service import outcome_service
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.game_logic.dice import SkillType, Difficulty
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
game_mechanics_bp = Blueprint('game_mechanics', __name__, url_prefix='/api/v1/game')
|
||||
|
||||
|
||||
# Valid skill types for API validation
|
||||
VALID_SKILL_TYPES = [skill.name.lower() for skill in SkillType]
|
||||
|
||||
# Valid difficulty names
|
||||
VALID_DIFFICULTIES = ["trivial", "easy", "medium", "hard", "very_hard", "nearly_impossible"]
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/check', methods=['POST'])
|
||||
@require_auth
|
||||
def perform_check():
|
||||
"""
|
||||
Perform a skill check or search action.
|
||||
|
||||
This endpoint determines the outcome of chance-based actions before
|
||||
they are passed to AI for narration. The result includes all dice
|
||||
roll details for UI display.
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"character_id": "...",
|
||||
"check_type": "search" | "skill",
|
||||
"skill": "perception", // Required for skill checks
|
||||
"dc": 15, // Optional, can use difficulty instead
|
||||
"difficulty": "medium", // Optional, alternative to dc
|
||||
"location_type": "forest", // For search checks
|
||||
"context": {} // Optional additional context
|
||||
}
|
||||
|
||||
Returns:
|
||||
For search checks:
|
||||
{
|
||||
"check_result": {
|
||||
"roll": 14,
|
||||
"modifier": 3,
|
||||
"total": 17,
|
||||
"dc": 15,
|
||||
"success": true,
|
||||
"margin": 2
|
||||
},
|
||||
"items_found": [...],
|
||||
"gold_found": 5
|
||||
}
|
||||
|
||||
For skill checks:
|
||||
{
|
||||
"check_result": {...},
|
||||
"context": {
|
||||
"skill_used": "persuasion",
|
||||
"stat_used": "charisma",
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"field": "body", "issue": "Missing JSON body"}
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
character_id = data.get("character_id")
|
||||
check_type = data.get("check_type")
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response(
|
||||
message="Character ID is required",
|
||||
details={"field": "character_id", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if not check_type:
|
||||
return validation_error_response(
|
||||
message="Check type is required",
|
||||
details={"field": "check_type", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if check_type not in ["search", "skill"]:
|
||||
return validation_error_response(
|
||||
message="Invalid check type",
|
||||
details={"field": "check_type", "issue": "Must be 'search' or 'skill'"}
|
||||
)
|
||||
|
||||
# Get character and verify ownership
|
||||
try:
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if character.user_id != user["user_id"]:
|
||||
return error_response(
|
||||
status_code=403,
|
||||
message="You don't have permission to access this character",
|
||||
error_code="FORBIDDEN"
|
||||
)
|
||||
except CharacterNotFound:
|
||||
return not_found_response(
|
||||
message=f"Character not found: {character_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("character_fetch_error", error=str(e), character_id=character_id)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to fetch character",
|
||||
error_code="CHARACTER_FETCH_ERROR"
|
||||
)
|
||||
|
||||
# Determine DC from difficulty name or direct value
|
||||
dc = data.get("dc")
|
||||
difficulty = data.get("difficulty")
|
||||
|
||||
if dc is None and difficulty:
|
||||
if difficulty.lower() not in VALID_DIFFICULTIES:
|
||||
return validation_error_response(
|
||||
message="Invalid difficulty",
|
||||
details={
|
||||
"field": "difficulty",
|
||||
"issue": f"Must be one of: {', '.join(VALID_DIFFICULTIES)}"
|
||||
}
|
||||
)
|
||||
dc = outcome_service.get_dc_for_difficulty(difficulty)
|
||||
elif dc is None:
|
||||
# Default to medium difficulty
|
||||
dc = Difficulty.MEDIUM.value
|
||||
|
||||
# Validate DC range
|
||||
if not isinstance(dc, int) or dc < 1 or dc > 35:
|
||||
return validation_error_response(
|
||||
message="Invalid DC value",
|
||||
details={"field": "dc", "issue": "DC must be an integer between 1 and 35"}
|
||||
)
|
||||
|
||||
# Get optional bonus
|
||||
bonus = data.get("bonus", 0)
|
||||
if not isinstance(bonus, int):
|
||||
bonus = 0
|
||||
|
||||
# Perform the check based on type
|
||||
try:
|
||||
if check_type == "search":
|
||||
# Search check uses perception
|
||||
location_type = data.get("location_type", "default")
|
||||
outcome = outcome_service.determine_search_outcome(
|
||||
character=character,
|
||||
location_type=location_type,
|
||||
dc=dc,
|
||||
bonus=bonus
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"search_check_performed",
|
||||
user_id=user["user_id"],
|
||||
character_id=character_id,
|
||||
location_type=location_type,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
|
||||
return success_response(result=outcome.to_dict())
|
||||
|
||||
else: # skill check
|
||||
skill = data.get("skill")
|
||||
if not skill:
|
||||
return validation_error_response(
|
||||
message="Skill is required for skill checks",
|
||||
details={"field": "skill", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
skill_lower = skill.lower()
|
||||
if skill_lower not in VALID_SKILL_TYPES:
|
||||
return validation_error_response(
|
||||
message="Invalid skill type",
|
||||
details={
|
||||
"field": "skill",
|
||||
"issue": f"Must be one of: {', '.join(VALID_SKILL_TYPES)}"
|
||||
}
|
||||
)
|
||||
|
||||
# Convert to SkillType enum
|
||||
skill_type = SkillType[skill.upper()]
|
||||
|
||||
# Get additional context
|
||||
context = data.get("context", {})
|
||||
|
||||
outcome = outcome_service.determine_skill_check_outcome(
|
||||
character=character,
|
||||
skill_type=skill_type,
|
||||
dc=dc,
|
||||
bonus=bonus,
|
||||
context=context
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"skill_check_performed",
|
||||
user_id=user["user_id"],
|
||||
character_id=character_id,
|
||||
skill=skill_lower,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
|
||||
return success_response(result=outcome.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"check_error",
|
||||
error=str(e),
|
||||
check_type=check_type,
|
||||
character_id=character_id
|
||||
)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to perform check",
|
||||
error_code="CHECK_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/skills', methods=['GET'])
|
||||
def list_skills():
|
||||
"""
|
||||
List all available skill types.
|
||||
|
||||
Returns the skill types available for skill checks,
|
||||
along with their associated base stats.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "perception",
|
||||
"stat": "wisdom",
|
||||
"description": "..."
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
skills = []
|
||||
for skill in SkillType:
|
||||
skills.append({
|
||||
"name": skill.name.lower(),
|
||||
"stat": skill.value,
|
||||
})
|
||||
|
||||
return success_response(result={"skills": skills})
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/difficulties', methods=['GET'])
|
||||
def list_difficulties():
|
||||
"""
|
||||
List all difficulty levels and their DC values.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"difficulties": [
|
||||
{"name": "trivial", "dc": 5},
|
||||
{"name": "easy", "dc": 10},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
difficulties = []
|
||||
for diff in Difficulty:
|
||||
difficulties.append({
|
||||
"name": diff.name.lower(),
|
||||
"dc": diff.value,
|
||||
})
|
||||
|
||||
return success_response(result={"difficulties": difficulties})
|
||||
60
api/app/api/health.py
Normal file
60
api/app/api/health.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Health Check API Blueprint
|
||||
|
||||
This module provides a simple health check endpoint for monitoring
|
||||
and testing API connectivity.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
from app.utils.response import success_response
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
health_bp = Blueprint('health', __name__, url_prefix='/api/v1')
|
||||
|
||||
|
||||
@health_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns basic service status and version information.
|
||||
Useful for monitoring, load balancers, and testing API connectivity.
|
||||
|
||||
Returns:
|
||||
JSON response with status "ok" and version info
|
||||
|
||||
Example:
|
||||
GET /api/v1/health
|
||||
|
||||
Response:
|
||||
{
|
||||
"app": "Code of Conquest",
|
||||
"version": "0.1.0",
|
||||
"status": 200,
|
||||
"timestamp": "2025-11-16T...",
|
||||
"result": {
|
||||
"status": "ok",
|
||||
"service": "Code of Conquest API",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"error": null,
|
||||
"meta": {}
|
||||
}
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
logger.debug("Health check requested")
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"status": "ok",
|
||||
"service": "Code of Conquest API",
|
||||
"version": config.app.version
|
||||
}
|
||||
)
|
||||
71
api/app/api/jobs.py
Normal file
71
api/app/api/jobs.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Jobs API Blueprint
|
||||
|
||||
This module provides API endpoints for job status polling:
|
||||
- Get job status
|
||||
- Get job result
|
||||
|
||||
All endpoints require authentication.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from app.tasks.ai_tasks import get_job_status, get_job_result
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
not_found_response,
|
||||
error_response
|
||||
)
|
||||
from app.utils.auth import require_auth
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
jobs_bp = Blueprint('jobs', __name__)
|
||||
|
||||
|
||||
@jobs_bp.route('/api/v1/jobs/<job_id>/status', methods=['GET'])
|
||||
@require_auth
|
||||
def job_status(job_id: str):
|
||||
"""
|
||||
Get the status of an AI job.
|
||||
|
||||
Args:
|
||||
job_id: The job ID returned from action submission
|
||||
|
||||
Returns:
|
||||
JSON response with job status and result if completed
|
||||
"""
|
||||
try:
|
||||
status_info = get_job_status(job_id)
|
||||
|
||||
if not status_info or status_info.get('status') == 'not_found':
|
||||
return not_found_response(f"Job not found: {job_id}")
|
||||
|
||||
# If completed, include the result
|
||||
if status_info.get('status') == 'completed':
|
||||
result = get_job_result(job_id)
|
||||
if result:
|
||||
status_info['dm_response'] = result.get('dm_response', '')
|
||||
status_info['tokens_used'] = result.get('tokens_used', 0)
|
||||
status_info['model'] = result.get('model', '')
|
||||
|
||||
logger.debug("Job status retrieved",
|
||||
job_id=job_id,
|
||||
status=status_info.get('status'))
|
||||
|
||||
return success_response(status_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get job status",
|
||||
job_id=job_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="JOB_STATUS_ERROR",
|
||||
message="Failed to get job status"
|
||||
)
|
||||
429
api/app/api/npcs.py
Normal file
429
api/app/api/npcs.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
NPC API Blueprint
|
||||
|
||||
This module provides API endpoints for NPC interactions:
|
||||
- Get NPC details
|
||||
- Talk to NPC (queues AI dialogue generation)
|
||||
- Get NPCs at location
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.session_service import get_session_service, SessionNotFound
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.npc_loader import get_npc_loader
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.tasks.ai_tasks import enqueue_ai_task, TaskType
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
accepted_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
npcs_bp = Blueprint('npcs', __name__)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_npc_details(npc_id: str):
|
||||
"""
|
||||
Get NPC details with knowledge filtered by character interaction state.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID to get details for
|
||||
|
||||
Query params:
|
||||
character_id: Optional character ID for filtering revealed secrets
|
||||
|
||||
Returns:
|
||||
JSON response with NPC details
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
character_id = request.args.get('character_id')
|
||||
|
||||
# Load NPC
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
npc_data = npc.to_dict()
|
||||
|
||||
# Filter knowledge based on character interaction state
|
||||
if character_id:
|
||||
try:
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id, user.id)
|
||||
|
||||
if character:
|
||||
# Get revealed secrets based on conditions
|
||||
revealed = character_service.check_npc_secret_conditions(character, npc)
|
||||
|
||||
# Build available knowledge (public + revealed)
|
||||
available_knowledge = []
|
||||
if npc.knowledge:
|
||||
available_knowledge.extend(npc.knowledge.public)
|
||||
available_knowledge.extend(revealed)
|
||||
|
||||
npc_data["available_knowledge"] = available_knowledge
|
||||
|
||||
# Remove secret knowledge from response
|
||||
if npc_data.get("knowledge"):
|
||||
npc_data["knowledge"]["secret"] = []
|
||||
npc_data["knowledge"]["will_share_if"] = []
|
||||
|
||||
# Add interaction summary
|
||||
interaction = character.npc_interactions.get(npc_id, {})
|
||||
npc_data["interaction_summary"] = {
|
||||
"interaction_count": interaction.get("interaction_count", 0),
|
||||
"relationship_level": interaction.get("relationship_level", 50),
|
||||
"first_met": interaction.get("first_met"),
|
||||
}
|
||||
|
||||
except CharacterNotFound:
|
||||
logger.debug("Character not found for NPC filter", character_id=character_id)
|
||||
|
||||
return success_response(npc_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get NPC details",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get NPC", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/talk', methods=['POST'])
|
||||
@require_auth
|
||||
def talk_to_npc(npc_id: str):
|
||||
"""
|
||||
Initiate conversation with an NPC.
|
||||
|
||||
Validates NPC is at current location, updates interaction state,
|
||||
and queues AI dialogue generation task.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID to talk to
|
||||
|
||||
Request body:
|
||||
session_id: Active session ID
|
||||
topic: Conversation topic/opener (default: "greeting")
|
||||
player_response: What the player says to the NPC (overrides topic if provided)
|
||||
|
||||
Returns:
|
||||
JSON response with job_id for polling result
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
session_id = data.get('session_id')
|
||||
# player_response overrides topic for bidirectional dialogue
|
||||
player_response = data.get('player_response')
|
||||
topic = player_response if player_response else data.get('topic', 'greeting')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id is required")
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Load NPC
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Validate NPC is at current location
|
||||
if npc.location_id != session.game_state.current_location:
|
||||
logger.warning("NPC not at current location",
|
||||
npc_id=npc_id,
|
||||
npc_location=npc.location_id,
|
||||
current_location=session.game_state.current_location)
|
||||
return error_response("NPC is not at your current location", 400)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
# Get or create interaction state
|
||||
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
interaction = character.npc_interactions.get(npc_id, {})
|
||||
|
||||
if not interaction:
|
||||
# First meeting
|
||||
interaction = {
|
||||
"npc_id": npc_id,
|
||||
"first_met": now,
|
||||
"last_interaction": now,
|
||||
"interaction_count": 1,
|
||||
"revealed_secrets": [],
|
||||
"relationship_level": 50,
|
||||
"custom_flags": {},
|
||||
}
|
||||
else:
|
||||
# Update existing interaction
|
||||
interaction["last_interaction"] = now
|
||||
interaction["interaction_count"] = interaction.get("interaction_count", 0) + 1
|
||||
|
||||
# Check for newly revealed secrets
|
||||
revealed = character_service.check_npc_secret_conditions(character, npc)
|
||||
|
||||
# Update character with new interaction state
|
||||
character_service.update_npc_interaction(
|
||||
character.character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
interaction
|
||||
)
|
||||
|
||||
# Build NPC knowledge for AI context
|
||||
npc_knowledge = []
|
||||
if npc.knowledge:
|
||||
npc_knowledge.extend(npc.knowledge.public)
|
||||
npc_knowledge.extend(revealed)
|
||||
|
||||
# Get previous dialogue history for context (last 3 exchanges)
|
||||
previous_dialogue = character_service.get_npc_dialogue_history(
|
||||
character.character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
limit=3
|
||||
)
|
||||
|
||||
# Prepare AI context
|
||||
task_context = {
|
||||
"session_id": session_id,
|
||||
"character_id": character.character_id,
|
||||
"character": character.to_story_dict(),
|
||||
"npc": npc.to_story_dict(),
|
||||
"npc_full": npc.to_dict(), # Full NPC data for reference
|
||||
"conversation_topic": topic,
|
||||
"game_state": session.game_state.to_dict(),
|
||||
"npc_knowledge": npc_knowledge,
|
||||
"revealed_secrets": revealed,
|
||||
"interaction_count": interaction["interaction_count"],
|
||||
"relationship_level": interaction.get("relationship_level", 50),
|
||||
"previous_dialogue": previous_dialogue, # Pass conversation history
|
||||
}
|
||||
|
||||
# Enqueue AI task
|
||||
result = enqueue_ai_task(
|
||||
task_type=TaskType.NPC_DIALOGUE,
|
||||
user_id=user.id,
|
||||
context=task_context,
|
||||
priority="normal",
|
||||
session_id=session_id,
|
||||
character_id=character.character_id
|
||||
)
|
||||
|
||||
logger.info("NPC dialogue task queued",
|
||||
user_id=user.id,
|
||||
npc_id=npc_id,
|
||||
job_id=result.get('job_id'),
|
||||
interaction_count=interaction["interaction_count"])
|
||||
|
||||
return accepted_response({
|
||||
"job_id": result.get('job_id'),
|
||||
"status": "queued",
|
||||
"message": f"Starting conversation with {npc.name}...",
|
||||
"npc_name": npc.name,
|
||||
"npc_role": npc.role,
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to talk to NPC",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to start conversation", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/at-location/<location_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_npcs_at_location(location_id: str):
|
||||
"""
|
||||
Get all NPCs at a specific location.
|
||||
|
||||
Path params:
|
||||
location_id: Location ID to get NPCs for
|
||||
|
||||
Returns:
|
||||
JSON response with list of NPCs at location
|
||||
"""
|
||||
try:
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
npcs_list = []
|
||||
for npc in npcs:
|
||||
npcs_list.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
"tags": npc.tags,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location_id": location_id,
|
||||
"npcs": npcs_list,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get NPCs at location",
|
||||
location_id=location_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get NPCs", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/relationship', methods=['POST'])
|
||||
@require_auth
|
||||
def adjust_npc_relationship(npc_id: str):
|
||||
"""
|
||||
Adjust relationship level with an NPC.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID
|
||||
|
||||
Request body:
|
||||
character_id: Character ID
|
||||
adjustment: Amount to add/subtract (can be negative)
|
||||
|
||||
Returns:
|
||||
JSON response with updated relationship level
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
character_id = data.get('character_id')
|
||||
adjustment = data.get('adjustment', 0)
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response("character_id is required")
|
||||
|
||||
if not isinstance(adjustment, int):
|
||||
return validation_error_response("adjustment must be an integer")
|
||||
|
||||
# Validate NPC exists
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Adjust relationship
|
||||
character_service = get_character_service()
|
||||
character = character_service.adjust_npc_relationship(
|
||||
character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
adjustment
|
||||
)
|
||||
|
||||
new_level = character.npc_interactions.get(npc_id, {}).get("relationship_level", 50)
|
||||
|
||||
logger.info("NPC relationship adjusted",
|
||||
npc_id=npc_id,
|
||||
character_id=character_id,
|
||||
adjustment=adjustment,
|
||||
new_level=new_level)
|
||||
|
||||
return success_response({
|
||||
"npc_id": npc_id,
|
||||
"relationship_level": new_level,
|
||||
})
|
||||
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to adjust NPC relationship",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to adjust relationship", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/flag', methods=['POST'])
|
||||
@require_auth
|
||||
def set_npc_flag(npc_id: str):
|
||||
"""
|
||||
Set a custom flag on NPC interaction (e.g., "helped_with_rats": true).
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID
|
||||
|
||||
Request body:
|
||||
character_id: Character ID
|
||||
flag_name: Name of the flag
|
||||
flag_value: Value to set
|
||||
|
||||
Returns:
|
||||
JSON response confirming flag was set
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
character_id = data.get('character_id')
|
||||
flag_name = data.get('flag_name')
|
||||
flag_value = data.get('flag_value')
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response("character_id is required")
|
||||
if not flag_name:
|
||||
return validation_error_response("flag_name is required")
|
||||
|
||||
# Validate NPC exists
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Set flag
|
||||
character_service = get_character_service()
|
||||
character_service.set_npc_custom_flag(
|
||||
character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
flag_name,
|
||||
flag_value
|
||||
)
|
||||
|
||||
logger.info("NPC flag set",
|
||||
npc_id=npc_id,
|
||||
character_id=character_id,
|
||||
flag_name=flag_name)
|
||||
|
||||
return success_response({
|
||||
"npc_id": npc_id,
|
||||
"flag_name": flag_name,
|
||||
"flag_value": flag_value,
|
||||
})
|
||||
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to set NPC flag",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to set flag", 500)
|
||||
604
api/app/api/sessions.py
Normal file
604
api/app/api/sessions.py
Normal file
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
Sessions API Blueprint
|
||||
|
||||
This module provides API endpoints for story session management:
|
||||
- Create new solo session
|
||||
- Get session state
|
||||
- Take action (async AI processing)
|
||||
- Get conversation history
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
|
||||
from app.services.session_service import (
|
||||
get_session_service,
|
||||
SessionNotFound,
|
||||
SessionLimitExceeded,
|
||||
SessionValidationError
|
||||
)
|
||||
from app.services.character_service import CharacterNotFound, get_character_service
|
||||
from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded
|
||||
from app.services.action_prompt_loader import ActionPromptLoader, ActionPromptNotFoundError
|
||||
from app.services.outcome_service import outcome_service
|
||||
from app.tasks.ai_tasks import enqueue_ai_task, TaskType, get_job_status, get_job_result
|
||||
from app.ai.model_selector import UserTier
|
||||
from app.models.action_prompt import LocationType
|
||||
from app.game_logic.dice import SkillType
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
accepted_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response,
|
||||
rate_limit_exceeded_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
sessions_bp = Blueprint('sessions', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_character_id(character_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate character ID format.
|
||||
|
||||
Args:
|
||||
character_id: Character ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not character_id:
|
||||
return False, "Character ID is required"
|
||||
|
||||
if not isinstance(character_id, str):
|
||||
return False, "Character ID must be a string"
|
||||
|
||||
if len(character_id) > 100:
|
||||
return False, "Character ID is too long"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_action_request(data: dict, user_tier: UserTier) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate action request data.
|
||||
|
||||
Args:
|
||||
data: Request JSON data
|
||||
user_tier: User's subscription tier
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
action_type = data.get('action_type')
|
||||
|
||||
if action_type != 'button':
|
||||
return False, "action_type must be 'button'"
|
||||
|
||||
if not data.get('prompt_id'):
|
||||
return False, "prompt_id is required for button actions"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_user_tier_from_user(user) -> UserTier:
|
||||
"""
|
||||
Get UserTier enum from user object.
|
||||
|
||||
Args:
|
||||
user: User object from auth
|
||||
|
||||
Returns:
|
||||
UserTier enum value
|
||||
"""
|
||||
# Map user tier string to UserTier enum
|
||||
tier_mapping = {
|
||||
'free': UserTier.FREE,
|
||||
'basic': UserTier.BASIC,
|
||||
'premium': UserTier.PREMIUM,
|
||||
'elite': UserTier.ELITE
|
||||
}
|
||||
|
||||
user_tier_str = getattr(user, 'tier', 'free').lower()
|
||||
return tier_mapping.get(user_tier_str, UserTier.FREE)
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions', methods=['GET'])
|
||||
@require_auth
|
||||
def list_sessions():
|
||||
"""
|
||||
List user's active game sessions.
|
||||
|
||||
Returns all active sessions for the authenticated user with basic session info.
|
||||
|
||||
Returns:
|
||||
JSON response with list of sessions
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
session_service = get_session_service()
|
||||
|
||||
# Get user's active sessions
|
||||
sessions = session_service.get_user_sessions(user_id, active_only=True)
|
||||
|
||||
# Build response with basic session info
|
||||
sessions_list = []
|
||||
for session in sessions:
|
||||
sessions_list.append({
|
||||
'session_id': session.session_id,
|
||||
'character_id': session.solo_character_id,
|
||||
'turn_number': session.turn_number,
|
||||
'status': session.status.value,
|
||||
'created_at': session.created_at,
|
||||
'last_activity': session.last_activity,
|
||||
'game_state': {
|
||||
'current_location': session.game_state.current_location,
|
||||
'location_type': session.game_state.location_type.value
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("Sessions listed successfully",
|
||||
user_id=user_id,
|
||||
count=len(sessions_list))
|
||||
|
||||
return success_response(sessions_list)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list sessions", error=str(e))
|
||||
return error_response(f"Failed to list sessions: {str(e)}", 500)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions', methods=['POST'])
|
||||
@require_auth
|
||||
def create_session():
|
||||
"""
|
||||
Create a new solo game session.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"character_id": "char_456"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: Session created with initial state
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
404: Character not found
|
||||
409: Session limit exceeded
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Creating new session")
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
|
||||
# Parse and validate request
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return validation_error_response("Request body is required")
|
||||
|
||||
character_id = data.get('character_id')
|
||||
is_valid, error_msg = validate_character_id(character_id)
|
||||
if not is_valid:
|
||||
return validation_error_response(error_msg)
|
||||
|
||||
# Create session
|
||||
session_service = get_session_service()
|
||||
session = session_service.create_solo_session(
|
||||
user_id=user_id,
|
||||
character_id=character_id
|
||||
)
|
||||
|
||||
logger.info("Session created successfully",
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
# Return session data
|
||||
return created_response({
|
||||
"session_id": session.session_id,
|
||||
"character_id": session.solo_character_id,
|
||||
"turn_number": session.turn_number,
|
||||
"game_state": {
|
||||
"current_location": session.game_state.current_location,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
"active_quests": session.game_state.active_quests
|
||||
}
|
||||
})
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for session creation",
|
||||
error=str(e))
|
||||
return not_found_response("Character not found")
|
||||
|
||||
except SessionLimitExceeded as e:
|
||||
logger.warning("Session limit exceeded",
|
||||
user_id=user_id if 'user_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
status=409,
|
||||
code="SESSION_LIMIT_EXCEEDED",
|
||||
message="Maximum active sessions limit reached (5). Please end an existing session first."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create session",
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="SESSION_CREATE_ERROR",
|
||||
message="Failed to create session"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>/action', methods=['POST'])
|
||||
@require_auth
|
||||
def take_action(session_id: str):
|
||||
"""
|
||||
Submit an action for AI processing (async).
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"action_type": "button",
|
||||
"prompt_id": "ask_locals"
|
||||
}
|
||||
|
||||
Returns:
|
||||
202: Action queued for processing
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
403: Action not available for tier/location
|
||||
404: Session not found
|
||||
429: Rate limit exceeded
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Processing action request", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
user_tier = get_user_tier_from_user(user)
|
||||
|
||||
# Verify session ownership and get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Parse and validate request
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return validation_error_response("Request body is required")
|
||||
|
||||
is_valid, error_msg = validate_action_request(data, user_tier)
|
||||
if not is_valid:
|
||||
return validation_error_response(error_msg)
|
||||
|
||||
# Check rate limit
|
||||
rate_limiter = RateLimiterService()
|
||||
|
||||
try:
|
||||
rate_limiter.check_rate_limit(user_id, user_tier)
|
||||
except RateLimitExceeded as e:
|
||||
logger.warning("Rate limit exceeded",
|
||||
user_id=user_id,
|
||||
tier=user_tier.value)
|
||||
return rate_limit_exceeded_response(
|
||||
message=f"Daily turn limit reached ({e.limit} turns). Resets at {e.reset_time.strftime('%H:%M UTC')}"
|
||||
)
|
||||
|
||||
# Build action context for AI task
|
||||
prompt_id = data.get('prompt_id')
|
||||
|
||||
# Validate prompt exists and is available
|
||||
loader = ActionPromptLoader()
|
||||
try:
|
||||
action_prompt = loader.get_action_by_id(prompt_id)
|
||||
except ActionPromptNotFoundError:
|
||||
return validation_error_response(f"Invalid prompt_id: {prompt_id}")
|
||||
|
||||
# Check if action is available for user's tier and location
|
||||
location_type = session.game_state.location_type
|
||||
if not action_prompt.is_available(user_tier, location_type):
|
||||
return error_response(
|
||||
status=403,
|
||||
code="ACTION_NOT_AVAILABLE",
|
||||
message="This action is not available for your tier or location"
|
||||
)
|
||||
|
||||
action_text = action_prompt.display_text
|
||||
dm_prompt_template = action_prompt.dm_prompt_template
|
||||
|
||||
# Fetch character data for AI context
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user_id)
|
||||
if not character:
|
||||
return not_found_response(f"Character {session.solo_character_id} not found")
|
||||
|
||||
# Perform dice check if action requires it
|
||||
check_outcome = None
|
||||
if action_prompt.requires_check:
|
||||
check_req = action_prompt.requires_check
|
||||
location_type_str = session.game_state.location_type.value if hasattr(session.game_state.location_type, 'value') else str(session.game_state.location_type)
|
||||
|
||||
# Get DC from difficulty
|
||||
dc = outcome_service.get_dc_for_difficulty(check_req.difficulty)
|
||||
|
||||
if check_req.check_type == "search":
|
||||
# Search check - uses perception and returns items/gold
|
||||
outcome = outcome_service.determine_search_outcome(
|
||||
character=character,
|
||||
location_type=location_type_str,
|
||||
dc=dc
|
||||
)
|
||||
check_outcome = outcome.to_dict()
|
||||
|
||||
logger.info(
|
||||
"Search check performed",
|
||||
character_id=character.character_id,
|
||||
success=outcome.check_result.success,
|
||||
items_found=len(outcome.items_found),
|
||||
gold_found=outcome.gold_found
|
||||
)
|
||||
elif check_req.check_type == "skill" and check_req.skill:
|
||||
# Skill check - generic skill vs DC
|
||||
try:
|
||||
skill_type = SkillType[check_req.skill.upper()]
|
||||
outcome = outcome_service.determine_skill_check_outcome(
|
||||
character=character,
|
||||
skill_type=skill_type,
|
||||
dc=dc
|
||||
)
|
||||
check_outcome = outcome.to_dict()
|
||||
|
||||
logger.info(
|
||||
"Skill check performed",
|
||||
character_id=character.character_id,
|
||||
skill=check_req.skill,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Invalid skill type in action prompt",
|
||||
prompt_id=action_prompt.prompt_id,
|
||||
skill=check_req.skill,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Queue AI task
|
||||
# Use trimmed character data for AI prompts (reduces tokens, focuses on story-relevant info)
|
||||
task_context = {
|
||||
"session_id": session_id,
|
||||
"character_id": session.solo_character_id,
|
||||
"action": action_text,
|
||||
"prompt_id": prompt_id,
|
||||
"dm_prompt_template": dm_prompt_template,
|
||||
"character": character.to_story_dict(),
|
||||
"game_state": session.game_state.to_dict(),
|
||||
"turn_number": session.turn_number,
|
||||
"conversation_history": [entry.to_dict() for entry in session.conversation_history],
|
||||
"world_context": None, # TODO: Add world context source when available
|
||||
"check_outcome": check_outcome # Dice check result for predetermined outcomes
|
||||
}
|
||||
|
||||
result = enqueue_ai_task(
|
||||
task_type=TaskType.NARRATIVE,
|
||||
user_id=user_id,
|
||||
context=task_context,
|
||||
priority="normal",
|
||||
session_id=session_id,
|
||||
character_id=session.solo_character_id
|
||||
)
|
||||
|
||||
# Increment rate limit counter
|
||||
rate_limiter.increment_usage(user_id)
|
||||
|
||||
logger.info("Action queued for processing",
|
||||
session_id=session_id,
|
||||
job_id=result.get('job_id'),
|
||||
prompt_id=prompt_id)
|
||||
|
||||
return accepted_response({
|
||||
"job_id": result.get('job_id'),
|
||||
"status": result.get('status', 'queued'),
|
||||
"message": "Your action is being processed..."
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found for action",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to process action",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="ACTION_PROCESS_ERROR",
|
||||
message="Failed to process action"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_session_state(session_id: str):
|
||||
"""
|
||||
Get current session state with available actions.
|
||||
|
||||
Returns:
|
||||
200: Session state
|
||||
401: Not authenticated
|
||||
404: Session not found
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Getting session state", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
user_tier = get_user_tier_from_user(user)
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Get available actions based on location and tier
|
||||
loader = ActionPromptLoader()
|
||||
location_type = session.game_state.location_type
|
||||
|
||||
available_actions = []
|
||||
for action in loader.get_available_actions(user_tier, location_type):
|
||||
available_actions.append({
|
||||
"prompt_id": action.prompt_id,
|
||||
"display_text": action.display_text,
|
||||
"description": action.description,
|
||||
"category": action.category.value
|
||||
})
|
||||
|
||||
logger.debug("Session state retrieved",
|
||||
session_id=session_id,
|
||||
turn_number=session.turn_number)
|
||||
|
||||
return success_response({
|
||||
"session_id": session.session_id,
|
||||
"character_id": session.get_character_id(),
|
||||
"turn_number": session.turn_number,
|
||||
"status": session.status.value,
|
||||
"game_state": {
|
||||
"current_location": session.game_state.current_location,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
"active_quests": session.game_state.active_quests
|
||||
},
|
||||
"available_actions": available_actions
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get session state",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="SESSION_STATE_ERROR",
|
||||
message="Failed to get session state"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>/history', methods=['GET'])
|
||||
@require_auth
|
||||
def get_history(session_id: str):
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
Query Parameters:
|
||||
limit: Number of entries to return (default 20)
|
||||
offset: Number of entries to skip (default 0)
|
||||
|
||||
Returns:
|
||||
200: Paginated conversation history
|
||||
401: Not authenticated
|
||||
404: Session not found
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Getting conversation history", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
|
||||
# Get pagination params
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
|
||||
# Clamp values
|
||||
limit = max(1, min(limit, 100)) # 1-100
|
||||
offset = max(0, offset)
|
||||
|
||||
# Verify session ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Get total history
|
||||
total_history = session.conversation_history
|
||||
total_turns = len(total_history)
|
||||
|
||||
# Apply pagination (from beginning)
|
||||
paginated_history = total_history[offset:offset + limit]
|
||||
|
||||
# Format history entries
|
||||
history_data = []
|
||||
for entry in paginated_history:
|
||||
# Handle timestamp - could be datetime object or already a string
|
||||
timestamp = None
|
||||
if hasattr(entry, 'timestamp') and entry.timestamp:
|
||||
if isinstance(entry.timestamp, str):
|
||||
timestamp = entry.timestamp
|
||||
else:
|
||||
timestamp = entry.timestamp.isoformat()
|
||||
|
||||
history_data.append({
|
||||
"turn": entry.turn,
|
||||
"action": entry.action,
|
||||
"dm_response": entry.dm_response,
|
||||
"timestamp": timestamp
|
||||
})
|
||||
|
||||
logger.debug("Conversation history retrieved",
|
||||
session_id=session_id,
|
||||
total=total_turns,
|
||||
returned=len(history_data))
|
||||
|
||||
return success_response({
|
||||
"total_turns": total_turns,
|
||||
"history": history_data,
|
||||
"pagination": {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + limit) < total_turns
|
||||
}
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found for history",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get conversation history",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="HISTORY_ERROR",
|
||||
message="Failed to get conversation history"
|
||||
)
|
||||
306
api/app/api/travel.py
Normal file
306
api/app/api/travel.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Travel API Blueprint
|
||||
|
||||
This module provides API endpoints for location-based travel:
|
||||
- Get available destinations
|
||||
- Travel to a location
|
||||
- Get current location details
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.session_service import get_session_service, SessionNotFound
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.services.npc_loader import get_npc_loader
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
travel_bp = Blueprint('travel', __name__)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/available', methods=['GET'])
|
||||
@require_auth
|
||||
def get_available_destinations():
|
||||
"""
|
||||
Get all locations the character can travel to.
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID
|
||||
|
||||
Returns:
|
||||
JSON response with list of available destinations
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
session_id = request.args.get('session_id')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id query parameter is required")
|
||||
|
||||
# Get session and verify ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Get character for discovered locations
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
# Load location details for each discovered location
|
||||
location_loader = get_location_loader()
|
||||
destinations = []
|
||||
|
||||
for loc_id in character.discovered_locations:
|
||||
# Skip current location
|
||||
if loc_id == session.game_state.current_location:
|
||||
continue
|
||||
|
||||
location = location_loader.load_location(loc_id)
|
||||
if location:
|
||||
destinations.append({
|
||||
"location_id": location.location_id,
|
||||
"name": location.name,
|
||||
"location_type": location.location_type.value,
|
||||
"region_id": location.region_id,
|
||||
"description": location.description[:200] + "..." if len(location.description) > 200 else location.description,
|
||||
})
|
||||
|
||||
logger.info("Retrieved available destinations",
|
||||
user_id=user.id,
|
||||
session_id=session_id,
|
||||
destination_count=len(destinations))
|
||||
|
||||
return success_response({
|
||||
"current_location": session.game_state.current_location,
|
||||
"destinations": destinations
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to get available destinations",
|
||||
error=str(e))
|
||||
return error_response("Failed to get destinations", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel', methods=['POST'])
|
||||
@require_auth
|
||||
def travel_to_location():
|
||||
"""
|
||||
Travel to a discovered location.
|
||||
|
||||
Request body:
|
||||
session_id: Active session ID
|
||||
location_id: Target location ID
|
||||
|
||||
Returns:
|
||||
JSON response with new location details and NPCs present
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
session_id = data.get('session_id')
|
||||
location_id = data.get('location_id')
|
||||
|
||||
# Validate required fields
|
||||
if not session_id:
|
||||
return validation_error_response("session_id is required")
|
||||
if not location_id:
|
||||
return validation_error_response("location_id is required")
|
||||
|
||||
# Get session and verify ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Get character and verify location is discovered
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
if location_id not in character.discovered_locations:
|
||||
logger.warning("Attempted travel to undiscovered location",
|
||||
user_id=user.id,
|
||||
character_id=character.character_id,
|
||||
location_id=location_id)
|
||||
return error_response("Location not discovered", 403)
|
||||
|
||||
# Load location details
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(location_id)
|
||||
|
||||
if not location:
|
||||
logger.error("Location not found in data files",
|
||||
location_id=location_id)
|
||||
return not_found_response("Location not found")
|
||||
|
||||
# Update session with new location
|
||||
session = session_service.update_location(
|
||||
session_id,
|
||||
location_id,
|
||||
location.location_type
|
||||
)
|
||||
|
||||
# Get NPCs at new location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
# Build NPC summary list
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
logger.info("Character traveled to location",
|
||||
user_id=user.id,
|
||||
session_id=session_id,
|
||||
location_id=location_id)
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
"game_state": session.game_state.to_dict(),
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to travel to location",
|
||||
error=str(e))
|
||||
return error_response("Failed to travel", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/location/<location_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_location_details(location_id: str):
|
||||
"""
|
||||
Get details about a specific location.
|
||||
|
||||
Path params:
|
||||
location_id: Location ID to get details for
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID (optional, for context)
|
||||
|
||||
Returns:
|
||||
JSON response with location details and NPCs
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Load location
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(location_id)
|
||||
|
||||
if not location:
|
||||
return not_found_response("Location not found")
|
||||
|
||||
# Get NPCs at location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get location details",
|
||||
location_id=location_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get location", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/current', methods=['GET'])
|
||||
@require_auth
|
||||
def get_current_location():
|
||||
"""
|
||||
Get details about the current location in a session.
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID
|
||||
|
||||
Returns:
|
||||
JSON response with current location details and NPCs
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
session_id = request.args.get('session_id')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id query parameter is required")
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
current_location_id = session.game_state.current_location
|
||||
|
||||
# Load location
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(current_location_id)
|
||||
|
||||
if not location:
|
||||
# Location not in data files - return basic info from session
|
||||
return success_response({
|
||||
"location": {
|
||||
"location_id": current_location_id,
|
||||
"name": current_location_id,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
},
|
||||
"npcs_present": [],
|
||||
})
|
||||
|
||||
# Get NPCs at location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(current_location_id)
|
||||
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to get current location",
|
||||
error=str(e))
|
||||
return error_response("Failed to get current location", 500)
|
||||
319
api/app/config.py
Normal file
319
api/app/config.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Configuration loader for Code of Conquest.
|
||||
|
||||
Loads configuration from YAML files and environment variables,
|
||||
providing typed access to all configuration values.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""Application configuration."""
|
||||
name: str
|
||||
version: str
|
||||
environment: str
|
||||
debug: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""Server configuration."""
|
||||
host: str
|
||||
port: int
|
||||
workers: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedisConfig:
|
||||
"""Redis configuration."""
|
||||
host: str
|
||||
port: int
|
||||
db: int
|
||||
max_connections: int
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Generate Redis URL."""
|
||||
return f"redis://{self.host}:{self.port}/{self.db}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RQConfig:
|
||||
"""RQ (Redis Queue) configuration."""
|
||||
queues: List[str]
|
||||
worker_timeout: int
|
||||
job_timeout: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIModelConfig:
|
||||
"""AI model configuration."""
|
||||
provider: str
|
||||
model: str
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIConfig:
|
||||
"""AI service configuration."""
|
||||
timeout: int
|
||||
max_retries: int
|
||||
cost_alert_threshold: float
|
||||
models: Dict[str, AIModelConfig] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitTier:
|
||||
"""Rate limit configuration for a subscription tier."""
|
||||
requests_per_minute: int
|
||||
ai_calls_per_day: int
|
||||
custom_actions_per_day: int # -1 for unlimited
|
||||
custom_action_char_limit: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitingConfig:
|
||||
"""Rate limiting configuration."""
|
||||
enabled: bool
|
||||
storage_url: str
|
||||
tiers: Dict[str, RateLimitTier] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthConfig:
|
||||
"""Authentication configuration."""
|
||||
cookie_name: str
|
||||
duration_normal: int
|
||||
duration_remember_me: int
|
||||
http_only: bool
|
||||
secure: bool
|
||||
same_site: str
|
||||
path: str
|
||||
password_min_length: int
|
||||
password_require_uppercase: bool
|
||||
password_require_lowercase: bool
|
||||
password_require_number: bool
|
||||
password_require_special: bool
|
||||
name_min_length: int
|
||||
name_max_length: int
|
||||
email_max_length: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionConfig:
|
||||
"""Game session configuration."""
|
||||
timeout_minutes: int
|
||||
auto_save_interval: int
|
||||
min_players: int
|
||||
max_players_by_tier: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketplaceConfig:
|
||||
"""Marketplace configuration."""
|
||||
auction_check_interval: int
|
||||
max_listings_by_tier: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CORSConfig:
|
||||
"""CORS configuration."""
|
||||
origins: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoggingConfig:
|
||||
"""Logging configuration."""
|
||||
level: str
|
||||
format: str
|
||||
handlers: List[str]
|
||||
file_path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""
|
||||
Main configuration container.
|
||||
|
||||
Loads configuration from YAML file based on environment,
|
||||
with overrides from environment variables.
|
||||
"""
|
||||
app: AppConfig
|
||||
server: ServerConfig
|
||||
redis: RedisConfig
|
||||
rq: RQConfig
|
||||
ai: AIConfig
|
||||
rate_limiting: RateLimitingConfig
|
||||
auth: AuthConfig
|
||||
session: SessionConfig
|
||||
marketplace: MarketplaceConfig
|
||||
cors: CORSConfig
|
||||
logging: LoggingConfig
|
||||
|
||||
# Environment variables (loaded from .env)
|
||||
secret_key: str = ""
|
||||
appwrite_endpoint: str = ""
|
||||
appwrite_project_id: str = ""
|
||||
appwrite_api_key: str = ""
|
||||
appwrite_database_id: str = ""
|
||||
anthropic_api_key: str = ""
|
||||
replicate_api_token: str = ""
|
||||
|
||||
@classmethod
|
||||
def load(cls, environment: Optional[str] = None) -> 'Config':
|
||||
"""
|
||||
Load configuration from YAML file and environment variables.
|
||||
|
||||
Args:
|
||||
environment: Environment name (development, production, etc.).
|
||||
If not provided, uses FLASK_ENV from environment.
|
||||
|
||||
Returns:
|
||||
Config: Loaded configuration object.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file not found.
|
||||
ValueError: If required environment variables missing.
|
||||
"""
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Determine environment
|
||||
if environment is None:
|
||||
environment = os.getenv('FLASK_ENV', 'development')
|
||||
|
||||
# Load YAML configuration
|
||||
config_path = os.path.join('config', f'{environment}.yaml')
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
raise FileNotFoundError(
|
||||
f"Configuration file not found: {config_path}"
|
||||
)
|
||||
|
||||
with open(config_path, 'r') as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
|
||||
# Parse configuration sections
|
||||
app_config = AppConfig(**config_data['app'])
|
||||
server_config = ServerConfig(**config_data['server'])
|
||||
redis_config = RedisConfig(**config_data['redis'])
|
||||
rq_config = RQConfig(**config_data['rq'])
|
||||
|
||||
# Parse AI models
|
||||
ai_models = {}
|
||||
for tier, model_data in config_data['ai']['models'].items():
|
||||
ai_models[tier] = AIModelConfig(**model_data)
|
||||
|
||||
ai_config = AIConfig(
|
||||
timeout=config_data['ai']['timeout'],
|
||||
max_retries=config_data['ai']['max_retries'],
|
||||
cost_alert_threshold=config_data['ai']['cost_alert_threshold'],
|
||||
models=ai_models
|
||||
)
|
||||
|
||||
# Parse rate limiting tiers
|
||||
rate_limit_tiers = {}
|
||||
for tier, tier_data in config_data['rate_limiting']['tiers'].items():
|
||||
rate_limit_tiers[tier] = RateLimitTier(**tier_data)
|
||||
|
||||
rate_limiting_config = RateLimitingConfig(
|
||||
enabled=config_data['rate_limiting']['enabled'],
|
||||
storage_url=config_data['rate_limiting']['storage_url'],
|
||||
tiers=rate_limit_tiers
|
||||
)
|
||||
|
||||
auth_config = AuthConfig(**config_data['auth'])
|
||||
session_config = SessionConfig(**config_data['session'])
|
||||
marketplace_config = MarketplaceConfig(**config_data['marketplace'])
|
||||
cors_config = CORSConfig(**config_data['cors'])
|
||||
logging_config = LoggingConfig(**config_data['logging'])
|
||||
|
||||
# Load environment variables (secrets)
|
||||
secret_key = os.getenv('SECRET_KEY')
|
||||
if not secret_key:
|
||||
raise ValueError("SECRET_KEY environment variable is required")
|
||||
|
||||
appwrite_endpoint = os.getenv('APPWRITE_ENDPOINT', '')
|
||||
appwrite_project_id = os.getenv('APPWRITE_PROJECT_ID', '')
|
||||
appwrite_api_key = os.getenv('APPWRITE_API_KEY', '')
|
||||
appwrite_database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
anthropic_api_key = os.getenv('ANTHROPIC_API_KEY', '')
|
||||
replicate_api_token = os.getenv('REPLICATE_API_TOKEN', '')
|
||||
|
||||
# Create and return config object
|
||||
return cls(
|
||||
app=app_config,
|
||||
server=server_config,
|
||||
redis=redis_config,
|
||||
rq=rq_config,
|
||||
ai=ai_config,
|
||||
rate_limiting=rate_limiting_config,
|
||||
auth=auth_config,
|
||||
session=session_config,
|
||||
marketplace=marketplace_config,
|
||||
cors=cors_config,
|
||||
logging=logging_config,
|
||||
secret_key=secret_key,
|
||||
appwrite_endpoint=appwrite_endpoint,
|
||||
appwrite_project_id=appwrite_project_id,
|
||||
appwrite_api_key=appwrite_api_key,
|
||||
appwrite_database_id=appwrite_database_id,
|
||||
anthropic_api_key=anthropic_api_key,
|
||||
replicate_api_token=replicate_api_token
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate configuration values.
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid.
|
||||
"""
|
||||
# Validate AI API keys if needed
|
||||
if self.app.environment == 'production':
|
||||
if not self.anthropic_api_key:
|
||||
raise ValueError(
|
||||
"ANTHROPIC_API_KEY required in production environment"
|
||||
)
|
||||
if not self.appwrite_endpoint or not self.appwrite_project_id:
|
||||
raise ValueError(
|
||||
"Appwrite configuration required in production environment"
|
||||
)
|
||||
|
||||
# Validate logging level
|
||||
valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
if self.logging.level not in valid_log_levels:
|
||||
raise ValueError(
|
||||
f"Invalid log level: {self.logging.level}. "
|
||||
f"Must be one of {valid_log_levels}"
|
||||
)
|
||||
|
||||
|
||||
# Global config instance (loaded lazily)
|
||||
_config: Optional[Config] = None
|
||||
|
||||
|
||||
def get_config(environment: Optional[str] = None) -> Config:
|
||||
"""
|
||||
Get the global configuration instance.
|
||||
|
||||
Args:
|
||||
environment: Optional environment override.
|
||||
|
||||
Returns:
|
||||
Config: Configuration object.
|
||||
"""
|
||||
global _config
|
||||
|
||||
if _config is None or environment is not None:
|
||||
_config = Config.load(environment)
|
||||
_config.validate()
|
||||
|
||||
return _config
|
||||
141
api/app/data/abilities/README.md
Normal file
141
api/app/data/abilities/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Ability Configuration Files
|
||||
|
||||
This directory contains YAML configuration files that define all abilities in the game.
|
||||
|
||||
## Format
|
||||
|
||||
Each ability is defined in a separate `.yaml` file with the following structure:
|
||||
|
||||
```yaml
|
||||
ability_id: "unique_identifier"
|
||||
name: "Display Name"
|
||||
description: "What the ability does"
|
||||
ability_type: "attack|spell|skill|item_use|defend"
|
||||
base_power: 0 # Base damage or healing
|
||||
damage_type: "physical|fire|ice|lightning|holy|shadow|poison"
|
||||
scaling_stat: "strength|dexterity|constitution|intelligence|wisdom|charisma"
|
||||
scaling_factor: 0.5 # Multiplier for scaling stat
|
||||
mana_cost: 0 # MP required to use
|
||||
cooldown: 0 # Turns before can be used again
|
||||
is_aoe: false # Whether it affects multiple targets
|
||||
target_count: 1 # Number of targets (0 = all enemies)
|
||||
effects_applied: [] # List of effects to apply on hit
|
||||
```
|
||||
|
||||
## Effect Format
|
||||
|
||||
Effects applied by abilities use this structure:
|
||||
|
||||
```yaml
|
||||
effects_applied:
|
||||
- effect_id: "unique_id"
|
||||
name: "Effect Name"
|
||||
effect_type: "buff|debuff|dot|hot|stun|shield"
|
||||
duration: 3 # Turns before expiration
|
||||
power: 5 # Damage/healing/modifier per turn
|
||||
stat_affected: "strength" # For buffs/debuffs only (null otherwise)
|
||||
stacks: 1 # Initial stack count
|
||||
max_stacks: 5 # Maximum stacks allowed
|
||||
source: "ability_id" # Which ability applied this
|
||||
```
|
||||
|
||||
## Effect Types
|
||||
|
||||
| Type | Power Usage | Example |
|
||||
|------|-------------|---------|
|
||||
| `buff` | Stat modifier (×stacks) | +5 strength per stack |
|
||||
| `debuff` | Stat modifier (×stacks) | -3 defense per stack |
|
||||
| `dot` | Damage per turn (×stacks) | 5 poison damage per turn |
|
||||
| `hot` | Healing per turn (×stacks) | 8 HP regeneration per turn |
|
||||
| `stun` | Not used | Prevents actions for duration |
|
||||
| `shield` | Shield strength (×stacks) | 50 damage absorption |
|
||||
|
||||
## Damage Calculation
|
||||
|
||||
Abilities calculate their final power using this formula:
|
||||
|
||||
```
|
||||
Final Power = base_power + (scaling_stat × scaling_factor)
|
||||
Minimum power is always 1
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- Fireball with 30 base_power, INT scaling 0.5, caster has 16 INT:
|
||||
- 30 + (16 × 0.5) = 38 power
|
||||
- Shield Bash with 10 base_power, STR scaling 0.5, caster has 20 STR:
|
||||
- 10 + (20 × 0.5) = 20 power
|
||||
|
||||
## Loading Abilities
|
||||
|
||||
Abilities are loaded via the `AbilityLoader` class:
|
||||
|
||||
```python
|
||||
from app.models.abilities import AbilityLoader
|
||||
|
||||
loader = AbilityLoader()
|
||||
fireball = loader.load_ability("fireball")
|
||||
power = fireball.calculate_power(caster_stats)
|
||||
```
|
||||
|
||||
## Example Abilities
|
||||
|
||||
### basic_attack.yaml
|
||||
- Simple physical attack
|
||||
- No mana cost or cooldown
|
||||
- Available to all characters
|
||||
|
||||
### fireball.yaml
|
||||
- Offensive spell
|
||||
- Deals fire damage + applies burning DoT
|
||||
- Costs 15 MP, no cooldown
|
||||
|
||||
### shield_bash.yaml
|
||||
- Vanguard class skill
|
||||
- Deals damage + stuns for 1 turn
|
||||
- Costs 5 MP, 2 turn cooldown
|
||||
|
||||
### heal.yaml
|
||||
- Luminary class spell
|
||||
- Restores health + applies regeneration HoT
|
||||
- Costs 10 MP, no cooldown
|
||||
|
||||
## Creating New Abilities
|
||||
|
||||
1. Create a new `.yaml` file in this directory
|
||||
2. Follow the format above
|
||||
3. Set appropriate values for your ability
|
||||
4. Ability will be automatically available via `AbilityLoader`
|
||||
5. No code changes required!
|
||||
|
||||
## Guidelines
|
||||
|
||||
**Power Scaling:**
|
||||
- Basic attacks: 5-10 base power
|
||||
- Spells: 20-40 base power
|
||||
- Skills: 10-25 base power
|
||||
- Scaling factor typically 0.5 (50% of stat)
|
||||
|
||||
**Mana Costs:**
|
||||
- Basic attacks: 0 MP
|
||||
- Low-tier spells: 5-10 MP
|
||||
- Mid-tier spells: 15-20 MP
|
||||
- High-tier spells: 25-30 MP
|
||||
- Ultimate abilities: 40-50 MP
|
||||
|
||||
**Cooldowns:**
|
||||
- No cooldown (0): Most spells and basic attacks
|
||||
- Short (1-2 turns): Common skills
|
||||
- Medium (3-5 turns): Powerful skills
|
||||
- Long (5-10 turns): Ultimate abilities
|
||||
|
||||
**Effect Duration:**
|
||||
- Instant effects (stun): 1 turn
|
||||
- Short DoT/HoT: 2-3 turns
|
||||
- Long DoT/HoT: 4-5 turns
|
||||
- Buffs/debuffs: 2-4 turns
|
||||
|
||||
**Effect Power:**
|
||||
- Weak DoT: 3-5 damage per turn
|
||||
- Medium DoT: 8-12 damage per turn
|
||||
- Strong DoT: 15-20 damage per turn
|
||||
- Stat modifiers: 3-10 points per stack
|
||||
16
api/app/data/abilities/basic_attack.yaml
Normal file
16
api/app/data/abilities/basic_attack.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Basic Attack - Default melee attack
|
||||
# Available to all characters, no mana cost, no cooldown
|
||||
|
||||
ability_id: "basic_attack"
|
||||
name: "Basic Attack"
|
||||
description: "A standard melee attack with your equipped weapon"
|
||||
ability_type: "attack"
|
||||
base_power: 5
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 0
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/fireball.yaml
Normal file
25
api/app/data/abilities/fireball.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Fireball - Offensive spell for Arcanist class
|
||||
# Deals fire damage and applies burning DoT
|
||||
|
||||
ability_id: "fireball"
|
||||
name: "Fireball"
|
||||
description: "Hurl a ball of fire at your enemies, dealing damage and burning them"
|
||||
ability_type: "spell"
|
||||
base_power: 30
|
||||
damage_type: "fire"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 15
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "burn"
|
||||
name: "Burning"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 5
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "fireball"
|
||||
26
api/app/data/abilities/heal.yaml
Normal file
26
api/app/data/abilities/heal.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Heal - Luminary class ability
|
||||
# Restores health to target ally
|
||||
|
||||
ability_id: "heal"
|
||||
name: "Heal"
|
||||
description: "Channel divine energy to restore an ally's health"
|
||||
ability_type: "spell"
|
||||
base_power: 25
|
||||
damage_type: "holy"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 10
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
# Healing is represented as negative DOT (HOT)
|
||||
- effect_id: "regeneration"
|
||||
name: "Regeneration"
|
||||
effect_type: "hot"
|
||||
duration: 2
|
||||
power: 5
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "heal"
|
||||
25
api/app/data/abilities/shield_bash.yaml
Normal file
25
api/app/data/abilities/shield_bash.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Shield Bash - Vanguard class ability
|
||||
# Deals damage and stuns the target
|
||||
|
||||
ability_id: "shield_bash"
|
||||
name: "Shield Bash"
|
||||
description: "Bash your enemy with your shield, dealing damage and stunning them briefly"
|
||||
ability_type: "skill"
|
||||
base_power: 10
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 5
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "stun"
|
||||
name: "Stunned"
|
||||
effect_type: "stun"
|
||||
duration: 1
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "shield_bash"
|
||||
295
api/app/data/action_prompts.yaml
Normal file
295
api/app/data/action_prompts.yaml
Normal file
@@ -0,0 +1,295 @@
|
||||
# Action Prompts Configuration
|
||||
#
|
||||
# Defines the predefined actions available to players during story progression.
|
||||
# Actions are filtered by user tier and location type.
|
||||
#
|
||||
# Tier hierarchy: FREE < BASIC < PREMIUM < ELITE
|
||||
# Location types: town, tavern, wilderness, dungeon, safe_area, library, any
|
||||
|
||||
action_prompts:
|
||||
# =============================================================================
|
||||
# FREE TIER ACTIONS (4)
|
||||
# Available to all players
|
||||
# =============================================================================
|
||||
|
||||
- prompt_id: ask_locals
|
||||
category: ask_question
|
||||
display_text: Ask locals for information
|
||||
description: Talk to NPCs to learn about quests, rumors, and local lore
|
||||
tier_required: free
|
||||
context_filter: [town, tavern]
|
||||
icon: chat
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
The player approaches locals in {{ game_state.current_location }} and asks for information.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
|
||||
Generate realistic NPC dialogue where locals share:
|
||||
- Local rumors or gossip
|
||||
- Information about nearby points of interest
|
||||
- Hints about potential quests or dangers
|
||||
- Useful tips for adventurers
|
||||
|
||||
The NPCs should have distinct personalities and speak naturally. Include 1-2 NPCs in the response.
|
||||
End with a hook that encourages further exploration or action.
|
||||
|
||||
- prompt_id: explore_area
|
||||
category: explore
|
||||
display_text: Explore the area
|
||||
description: Search your surroundings for points of interest, hidden paths, or useful items
|
||||
tier_required: free
|
||||
context_filter: [wilderness, dungeon]
|
||||
icon: compass
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
The player explores the area around {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Perception modifier: {{ character.stats.wisdom | default(10) }}
|
||||
|
||||
Describe what the player discovers:
|
||||
- Environmental details and atmosphere
|
||||
- Points of interest (paths, structures, natural features)
|
||||
- Any items, tracks, or clues found
|
||||
- Potential dangers or opportunities
|
||||
|
||||
Based on their Wisdom score, they may notice hidden details.
|
||||
|
||||
IMPORTANT: Do NOT automatically move the player to a new location.
|
||||
Present 2-3 options of what they can investigate or where they can go.
|
||||
Ask: "What would you like to investigate?" or "Which path do you take?"
|
||||
|
||||
- prompt_id: search_supplies
|
||||
category: gather_info
|
||||
display_text: Search for supplies
|
||||
description: Look for useful items, herbs, or materials in the environment
|
||||
tier_required: free
|
||||
context_filter: [any]
|
||||
icon: search
|
||||
cooldown_turns: 2
|
||||
requires_check:
|
||||
check_type: search
|
||||
difficulty: medium
|
||||
dm_prompt_template: |
|
||||
The player searches for supplies in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
|
||||
{% if check_outcome %}
|
||||
DICE CHECK RESULT: {{ check_outcome.check_result.success | string | upper }}
|
||||
- Roll: {{ check_outcome.check_result.roll }} + {{ check_outcome.check_result.modifier }} = {{ check_outcome.check_result.total }} vs DC {{ check_outcome.check_result.dc }}
|
||||
{% if check_outcome.check_result.success %}
|
||||
- Items found: {% for item in check_outcome.items_found %}{{ item.name }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
- Gold found: {{ check_outcome.gold_found }}
|
||||
|
||||
The player SUCCEEDED in their search. Narrate how they found these specific items.
|
||||
The items will be automatically added to their inventory - describe the discovery.
|
||||
{% else %}
|
||||
The player FAILED their search. Narrate the unsuccessful search attempt.
|
||||
They find nothing of value this time. Describe what they checked but came up empty.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Describe what supply sources or items they find based on location.
|
||||
{% endif %}
|
||||
|
||||
Keep the narration immersive and match the location type.
|
||||
|
||||
- prompt_id: rest_recover
|
||||
category: rest
|
||||
display_text: Rest and recover
|
||||
description: Take a short rest to recover health and stamina in a safe location
|
||||
tier_required: free
|
||||
context_filter: [town, tavern, safe_area]
|
||||
icon: bed
|
||||
cooldown_turns: 3
|
||||
dm_prompt_template: |
|
||||
The player wants to rest in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Current HP: {{ character.current_hp }}/{{ character.max_hp }}
|
||||
|
||||
For PAID rest (taverns/inns):
|
||||
- Describe the establishment and available rooms WITH PRICES
|
||||
- Ask which option they want before resting
|
||||
- DO NOT automatically spend their gold
|
||||
|
||||
For FREE rest (safe areas, campsites):
|
||||
- Describe finding a suitable spot
|
||||
- Describe the rest atmosphere and any ambient details
|
||||
- Dreams, thoughts, or reflections the character has
|
||||
|
||||
After they choose to rest:
|
||||
- The player recovers some health and feels refreshed
|
||||
- End with them ready to continue their adventure
|
||||
|
||||
# =============================================================================
|
||||
# PREMIUM TIER ACTIONS (+3)
|
||||
# Available to Premium and Elite subscribers
|
||||
# =============================================================================
|
||||
|
||||
- prompt_id: investigate_suspicious
|
||||
category: gather_info
|
||||
display_text: Investigate suspicious activity
|
||||
description: Look deeper into something that seems out of place or dangerous
|
||||
tier_required: premium
|
||||
context_filter: [any]
|
||||
icon: magnifying_glass
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
The player investigates suspicious activity in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Intelligence: {{ character.stats.intelligence | default(10) }}
|
||||
|
||||
Based on the location and recent events, describe:
|
||||
- What draws their attention
|
||||
- Clues or evidence they discover
|
||||
- Connections to larger mysteries or threats
|
||||
- Potential leads to follow
|
||||
|
||||
Higher Intelligence reveals more detailed observations.
|
||||
This should advance the story or reveal hidden plot elements.
|
||||
End with a clear lead or decision point.
|
||||
|
||||
- prompt_id: follow_lead
|
||||
category: travel
|
||||
display_text: Follow a lead
|
||||
description: Pursue information or tracks that could lead to your goal
|
||||
tier_required: premium
|
||||
context_filter: [any]
|
||||
icon: footprints
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
The player follows a lead from {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
|
||||
Based on recent discoveries or conversations, describe:
|
||||
- What lead they're following (information, tracks, rumors)
|
||||
- The journey or investigation process
|
||||
- What they find at the end of the trail
|
||||
- New information or locations discovered
|
||||
|
||||
This should move the story forward significantly.
|
||||
May lead to new areas, NPCs, or quest opportunities.
|
||||
End with a meaningful discovery or encounter.
|
||||
|
||||
- prompt_id: make_camp
|
||||
category: rest
|
||||
display_text: Make camp
|
||||
description: Set up a campsite in the wilderness for rest and preparation
|
||||
tier_required: premium
|
||||
context_filter: [wilderness]
|
||||
icon: campfire
|
||||
cooldown_turns: 5
|
||||
dm_prompt_template: |
|
||||
The player sets up camp in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Survival skill: {{ character.stats.wisdom | default(10) }}
|
||||
|
||||
Describe the camping experience:
|
||||
- Finding a suitable spot and setting up
|
||||
- Building a fire, preparing food
|
||||
- The night watch and any nocturnal events
|
||||
- Dreams or visions during sleep
|
||||
|
||||
Higher Wisdom means better campsite selection and awareness.
|
||||
May include random encounters (friendly travelers, animals, or threats).
|
||||
Player recovers health and is ready for the next day.
|
||||
|
||||
# =============================================================================
|
||||
# ELITE TIER ACTIONS (+3)
|
||||
# Available only to Elite subscribers
|
||||
# =============================================================================
|
||||
|
||||
- prompt_id: consult_texts
|
||||
category: special
|
||||
display_text: Consult ancient texts
|
||||
description: Study rare manuscripts and tomes for hidden knowledge and lore
|
||||
tier_required: elite
|
||||
context_filter: [library, town]
|
||||
icon: book
|
||||
cooldown_turns: 3
|
||||
dm_prompt_template: |
|
||||
The player consults ancient texts in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Intelligence: {{ character.stats.intelligence | default(10) }}
|
||||
|
||||
Describe the research session:
|
||||
- The library or collection they access
|
||||
- Specific tomes or scrolls they study
|
||||
- Ancient knowledge they uncover
|
||||
- Connections to current quests or mysteries
|
||||
|
||||
Higher Intelligence allows deeper understanding.
|
||||
May reveal:
|
||||
- Monster weaknesses or strategies
|
||||
- Hidden location details
|
||||
- Historical context for current events
|
||||
- Magical item properties or crafting recipes
|
||||
|
||||
End with actionable knowledge that helps their quest.
|
||||
|
||||
- prompt_id: commune_nature
|
||||
category: special
|
||||
display_text: Commune with nature
|
||||
description: Attune to the natural world to gain insights and guidance
|
||||
tier_required: elite
|
||||
context_filter: [wilderness]
|
||||
icon: leaf
|
||||
cooldown_turns: 4
|
||||
dm_prompt_template: |
|
||||
The player communes with nature in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Wisdom: {{ character.stats.wisdom | default(10) }}
|
||||
|
||||
Describe the mystical experience:
|
||||
- The ritual or meditation performed
|
||||
- Visions, sounds, or sensations received
|
||||
- Messages from the natural world
|
||||
- Animal messengers or nature spirits encountered
|
||||
|
||||
Higher Wisdom provides clearer visions.
|
||||
May reveal:
|
||||
- Danger ahead or safe paths
|
||||
- Weather changes or natural disasters
|
||||
- Animal behavior patterns
|
||||
- Locations of rare herbs or resources
|
||||
- Environmental quest hints
|
||||
|
||||
End with prophetic or practical guidance.
|
||||
|
||||
- prompt_id: seek_audience
|
||||
category: special
|
||||
display_text: Seek audience with authorities
|
||||
description: Request a meeting with local leaders, nobles, or officials
|
||||
tier_required: elite
|
||||
context_filter: [town]
|
||||
icon: crown
|
||||
cooldown_turns: 5
|
||||
dm_prompt_template: |
|
||||
The player seeks an audience with authorities in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Charisma: {{ character.stats.charisma | default(10) }}
|
||||
Reputation: {{ character.reputation | default('unknown') }}
|
||||
|
||||
Describe the audience:
|
||||
- The authority figure (mayor, lord, guild master, etc.)
|
||||
- The setting and formality of the meeting
|
||||
- The conversation and requests made
|
||||
- The authority's response and any tasks given
|
||||
|
||||
Higher Charisma and reputation improve reception.
|
||||
May result in:
|
||||
- Official quests with better rewards
|
||||
- Access to restricted areas
|
||||
- Political information or alliances
|
||||
- Resources or equipment grants
|
||||
- Letters of introduction
|
||||
|
||||
End with a clear outcome and next steps.
|
||||
264
api/app/data/classes/arcanist.yaml
Normal file
264
api/app/data/classes/arcanist.yaml
Normal file
@@ -0,0 +1,264 @@
|
||||
# Arcanist - Magic Burst
|
||||
# Flexible hybrid class: Choose Pyromancy (fire AoE) or Cryomancy (ice control)
|
||||
|
||||
class_id: arcanist
|
||||
name: Arcanist
|
||||
description: >
|
||||
A master of elemental magic who bends the forces of fire and ice to their will. Arcanists
|
||||
excel in devastating spell damage, capable of incinerating groups of foes or freezing
|
||||
enemies in place. Choose your element: embrace the flames or command the frost.
|
||||
|
||||
# Base stats (total: 65)
|
||||
base_stats:
|
||||
strength: 8 # Low physical power
|
||||
dexterity: 10 # Average agility
|
||||
constitution: 9 # Below average endurance
|
||||
intelligence: 15 # Exceptional magical power
|
||||
wisdom: 12 # Above average perception
|
||||
charisma: 11 # Above average social
|
||||
|
||||
starting_equipment:
|
||||
- worn_staff
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== PYROMANCY (Fire AoE) ====================
|
||||
- tree_id: pyromancy
|
||||
name: Pyromancy
|
||||
description: >
|
||||
The path of flame. Master destructive fire magic to incinerate your enemies
|
||||
with overwhelming area damage and burning DoTs.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: fireball
|
||||
name: Fireball
|
||||
description: Hurl a ball of flame at an enemy, dealing fire damage and igniting them.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- fireball
|
||||
|
||||
- skill_id: flame_attunement
|
||||
name: Flame Attunement
|
||||
description: Your affinity with fire magic increases your magical power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: flame_burst
|
||||
name: Flame Burst
|
||||
description: Release a burst of fire around you, damaging all nearby enemies.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- fireball
|
||||
effects:
|
||||
abilities:
|
||||
- flame_burst
|
||||
|
||||
- skill_id: burning_soul
|
||||
name: Burning Soul
|
||||
description: Your inner fire burns brighter, increasing fire damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- flame_attunement
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
combat_bonuses:
|
||||
fire_damage_bonus: 0.15 # +15% fire damage
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: inferno
|
||||
name: Inferno
|
||||
description: Summon a raging inferno that burns all enemies for 3 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- flame_burst
|
||||
effects:
|
||||
abilities:
|
||||
- inferno
|
||||
|
||||
- skill_id: combustion
|
||||
name: Combustion
|
||||
description: Your fire spells can cause targets to explode on death, damaging nearby enemies.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- burning_soul
|
||||
effects:
|
||||
passive_effects:
|
||||
- burning_enemies_explode_on_death
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: firestorm
|
||||
name: Firestorm
|
||||
description: Call down a storm of meteors on all enemies, dealing massive fire damage.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- inferno
|
||||
effects:
|
||||
abilities:
|
||||
- firestorm
|
||||
|
||||
- skill_id: pyroclasm
|
||||
name: Pyroclasm
|
||||
description: Your mastery of flame makes all fire spells more devastating.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- combustion
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 10
|
||||
combat_bonuses:
|
||||
fire_damage_bonus: 0.25 # Additional +25% fire damage
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: sun_burst
|
||||
name: Sun Burst
|
||||
description: Channel the power of the sun itself, dealing catastrophic fire damage to all enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- firestorm
|
||||
effects:
|
||||
abilities:
|
||||
- sun_burst
|
||||
|
||||
- skill_id: master_of_flame
|
||||
name: Master of Flame
|
||||
description: You are flame incarnate. Incredible fire magic bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- pyroclasm
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 20
|
||||
combat_bonuses:
|
||||
fire_damage_bonus: 0.50 # Additional +50% fire damage
|
||||
|
||||
# ==================== CRYOMANCY (Ice Control) ====================
|
||||
- tree_id: cryomancy
|
||||
name: Cryomancy
|
||||
description: >
|
||||
The path of frost. Master ice magic to freeze and slow enemies,
|
||||
controlling the battlefield with chilling precision.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: ice_shard
|
||||
name: Ice Shard
|
||||
description: Launch a shard of ice at an enemy, dealing damage and slowing them.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- ice_shard
|
||||
|
||||
- skill_id: frost_attunement
|
||||
name: Frost Attunement
|
||||
description: Your affinity with ice magic increases your magical power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: frozen_orb
|
||||
name: Frozen Orb
|
||||
description: Summon an orb of ice that explodes, freezing enemies in place for 1 turn.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- ice_shard
|
||||
effects:
|
||||
abilities:
|
||||
- frozen_orb
|
||||
|
||||
- skill_id: cold_embrace
|
||||
name: Cold Embrace
|
||||
description: The cold empowers you, increasing ice damage and mana.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- frost_attunement
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
combat_bonuses:
|
||||
ice_damage_bonus: 0.15 # +15% ice damage
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: blizzard
|
||||
name: Blizzard
|
||||
description: Summon a raging blizzard that damages and slows all enemies.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- frozen_orb
|
||||
effects:
|
||||
abilities:
|
||||
- blizzard
|
||||
|
||||
- skill_id: permafrost
|
||||
name: Permafrost
|
||||
description: Your ice magic becomes more potent, with longer freeze durations.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- cold_embrace
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
freeze_duration_bonus: 1 # +1 turn to freeze effects
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: glacial_spike
|
||||
name: Glacial Spike
|
||||
description: Impale an enemy with a massive ice spike, dealing heavy damage and freezing them.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- blizzard
|
||||
effects:
|
||||
abilities:
|
||||
- glacial_spike
|
||||
|
||||
- skill_id: ice_mastery
|
||||
name: Ice Mastery
|
||||
description: Your command of ice magic reaches new heights.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- permafrost
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 10
|
||||
combat_bonuses:
|
||||
ice_damage_bonus: 0.25 # Additional +25% ice damage
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: absolute_zero
|
||||
name: Absolute Zero
|
||||
description: Freeze all enemies solid for 2 turns while dealing massive damage over time.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- glacial_spike
|
||||
effects:
|
||||
abilities:
|
||||
- absolute_zero
|
||||
|
||||
- skill_id: winter_incarnate
|
||||
name: Winter Incarnate
|
||||
description: You become the embodiment of winter itself. Incredible ice magic bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- ice_mastery
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 20
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
ice_damage_bonus: 0.50 # Additional +50% ice damage
|
||||
265
api/app/data/classes/assassin.yaml
Normal file
265
api/app/data/classes/assassin.yaml
Normal file
@@ -0,0 +1,265 @@
|
||||
# Assassin - Critical/Stealth
|
||||
# Flexible hybrid class: Choose Shadow Dancer (stealth/evasion) or Blade Specialist (critical damage)
|
||||
|
||||
class_id: assassin
|
||||
name: Assassin
|
||||
description: >
|
||||
A deadly operative who strikes from the shadows. Assassins excel in precise, devastating attacks,
|
||||
capable of becoming an elusive phantom or a master of critical strikes. Choose your path: embrace
|
||||
the shadows or perfect the killing blow.
|
||||
|
||||
# Base stats (total: 65)
|
||||
base_stats:
|
||||
strength: 11 # Above average physical power
|
||||
dexterity: 15 # Exceptional agility
|
||||
constitution: 10 # Average endurance
|
||||
intelligence: 9 # Below average magic
|
||||
wisdom: 10 # Average perception
|
||||
charisma: 10 # Average social
|
||||
|
||||
starting_equipment:
|
||||
- rusty_dagger
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== SHADOW DANCER (Stealth/Evasion) ====================
|
||||
- tree_id: shadow_dancer
|
||||
name: Shadow Dancer
|
||||
description: >
|
||||
The path of the phantom. Master stealth and evasion to become untouchable,
|
||||
striking from darkness and vanishing before retaliation.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: shadowstep
|
||||
name: Shadowstep
|
||||
description: Teleport behind an enemy and strike, dealing bonus damage from behind.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- shadowstep
|
||||
|
||||
- skill_id: nimble
|
||||
name: Nimble
|
||||
description: Your natural agility is enhanced through training.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: smoke_bomb
|
||||
name: Smoke Bomb
|
||||
description: Throw a smoke bomb, becoming untargetable for 1 turn and gaining evasion bonus.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- shadowstep
|
||||
effects:
|
||||
abilities:
|
||||
- smoke_bomb
|
||||
|
||||
- skill_id: evasion_training
|
||||
name: Evasion Training
|
||||
description: Learn to anticipate and dodge incoming attacks.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- nimble
|
||||
effects:
|
||||
combat_bonuses:
|
||||
evasion_chance: 0.15 # +15% chance to evade attacks
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: vanish
|
||||
name: Vanish
|
||||
description: Disappear from the battlefield for 2 turns, removing all threat and repositioning.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- smoke_bomb
|
||||
effects:
|
||||
abilities:
|
||||
- vanish
|
||||
|
||||
- skill_id: shadow_form
|
||||
name: Shadow Form
|
||||
description: Your body becomes harder to hit, permanently increasing evasion.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- evasion_training
|
||||
effects:
|
||||
combat_bonuses:
|
||||
evasion_chance: 0.10 # Additional +10% evasion
|
||||
stat_bonuses:
|
||||
dexterity: 5
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: death_mark
|
||||
name: Death Mark
|
||||
description: Mark an enemy from stealth. Your next attack on them deals 200% damage.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- vanish
|
||||
effects:
|
||||
abilities:
|
||||
- death_mark
|
||||
|
||||
- skill_id: untouchable
|
||||
name: Untouchable
|
||||
description: Your mastery of evasion makes you extremely difficult to hit.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- shadow_form
|
||||
effects:
|
||||
combat_bonuses:
|
||||
evasion_chance: 0.15 # Additional +15% evasion
|
||||
stat_bonuses:
|
||||
dexterity: 10
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: shadow_assault
|
||||
name: Shadow Assault
|
||||
description: Strike all enemies in rapid succession from the shadows, guaranteed critical hits.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- death_mark
|
||||
effects:
|
||||
abilities:
|
||||
- shadow_assault
|
||||
|
||||
- skill_id: ghost
|
||||
name: Ghost
|
||||
description: Become one with the shadows. Massive evasion and dexterity bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- untouchable
|
||||
effects:
|
||||
combat_bonuses:
|
||||
evasion_chance: 0.20 # Additional +20% evasion (total can reach ~60%)
|
||||
stat_bonuses:
|
||||
dexterity: 15
|
||||
|
||||
# ==================== BLADE SPECIALIST (Critical Damage) ====================
|
||||
- tree_id: blade_specialist
|
||||
name: Blade Specialist
|
||||
description: >
|
||||
The path of precision. Master the art of the killing blow to deliver devastating
|
||||
critical strikes that end fights in seconds.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: precise_strike
|
||||
name: Precise Strike
|
||||
description: A carefully aimed attack with increased critical hit chance.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- precise_strike
|
||||
|
||||
- skill_id: keen_edge
|
||||
name: Keen Edge
|
||||
description: Sharpen your weapons to a razor edge, increasing critical chance.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.10 # +10% base crit
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: vital_strike
|
||||
name: Vital Strike
|
||||
description: Target vital points to deal massive critical damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- precise_strike
|
||||
effects:
|
||||
abilities:
|
||||
- vital_strike
|
||||
|
||||
- skill_id: deadly_precision
|
||||
name: Deadly Precision
|
||||
description: Your strikes become even more lethal.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- keen_edge
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.10 # Additional +10% crit
|
||||
crit_multiplier: 0.3 # +0.3 to crit multiplier
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: hemorrhage
|
||||
name: Hemorrhage
|
||||
description: Critical hits cause bleeding for 3 turns, dealing heavy damage over time.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- vital_strike
|
||||
effects:
|
||||
passive_effects:
|
||||
- crit_applies_bleed
|
||||
|
||||
- skill_id: surgical_strikes
|
||||
name: Surgical Strikes
|
||||
description: Every attack is a calculated execution.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- deadly_precision
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.15 # Additional +15% crit
|
||||
stat_bonuses:
|
||||
dexterity: 5
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: coup_de_grace
|
||||
name: Coup de Grace
|
||||
description: Execute targets below 25% HP instantly with a guaranteed critical.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- hemorrhage
|
||||
effects:
|
||||
abilities:
|
||||
- coup_de_grace
|
||||
|
||||
- skill_id: master_assassin
|
||||
name: Master Assassin
|
||||
description: Your expertise with blades reaches perfection.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- surgical_strikes
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.10 # Additional +10% crit
|
||||
crit_multiplier: 0.5 # +0.5 to crit multiplier
|
||||
stat_bonuses:
|
||||
strength: 5
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: thousand_cuts
|
||||
name: Thousand Cuts
|
||||
description: Unleash a flurry of blade strikes on a single target, each hit has 50% crit chance.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- coup_de_grace
|
||||
effects:
|
||||
abilities:
|
||||
- thousand_cuts
|
||||
|
||||
- skill_id: perfect_assassination
|
||||
name: Perfect Assassination
|
||||
description: Your mastery of the blade is unmatched. Incredible critical bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- master_assassin
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.20 # Additional +20% crit (total can reach ~75%)
|
||||
crit_multiplier: 1.0 # +1.0 to crit multiplier
|
||||
stat_bonuses:
|
||||
dexterity: 10
|
||||
strength: 10
|
||||
273
api/app/data/classes/lorekeeper.yaml
Normal file
273
api/app/data/classes/lorekeeper.yaml
Normal file
@@ -0,0 +1,273 @@
|
||||
# Lorekeeper - Support/Control
|
||||
# Flexible hybrid class: Choose Arcane Weaving (buffs/debuffs) or Illusionist (crowd control)
|
||||
|
||||
class_id: lorekeeper
|
||||
name: Lorekeeper
|
||||
description: >
|
||||
A master of arcane knowledge who manipulates reality through words and illusions. Lorekeepers
|
||||
excel in supporting allies and controlling enemies through clever magic and mental manipulation.
|
||||
Choose your art: weave arcane power or bend reality itself.
|
||||
|
||||
# Base stats (total: 67)
|
||||
base_stats:
|
||||
strength: 8 # Low physical power
|
||||
dexterity: 11 # Above average agility
|
||||
constitution: 10 # Average endurance
|
||||
intelligence: 13 # Above average magical power
|
||||
wisdom: 11 # Above average perception
|
||||
charisma: 14 # High social/performance
|
||||
|
||||
starting_equipment:
|
||||
- tome
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== ARCANE WEAVING (Buffs/Debuffs) ====================
|
||||
- tree_id: arcane_weaving
|
||||
name: Arcane Weaving
|
||||
description: >
|
||||
The path of the arcane weaver. Master supportive magic to enhance allies,
|
||||
weaken enemies, and turn the tide of battle through clever enchantments.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: arcane_brilliance
|
||||
name: Arcane Brilliance
|
||||
description: Grant an ally increased intelligence and magical power for 5 turns.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- arcane_brilliance
|
||||
|
||||
- skill_id: scholarly_mind
|
||||
name: Scholarly Mind
|
||||
description: Your extensive study enhances your magical knowledge.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: haste
|
||||
name: Haste
|
||||
description: Speed up an ally, granting them an extra action this turn.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- arcane_brilliance
|
||||
effects:
|
||||
abilities:
|
||||
- haste
|
||||
|
||||
- skill_id: arcane_mastery
|
||||
name: Arcane Mastery
|
||||
description: Your mastery of arcane arts increases all buff effectiveness.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- scholarly_mind
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
charisma: 3
|
||||
combat_bonuses:
|
||||
buff_power: 0.20 # +20% buff effectiveness
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: mass_enhancement
|
||||
name: Mass Enhancement
|
||||
description: Enhance all allies at once, increasing their stats for 5 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- haste
|
||||
effects:
|
||||
abilities:
|
||||
- mass_enhancement
|
||||
|
||||
- skill_id: arcane_weakness
|
||||
name: Arcane Weakness
|
||||
description: Curse an enemy with weakness, reducing their stats and damage.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- arcane_mastery
|
||||
effects:
|
||||
abilities:
|
||||
- arcane_weakness
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: time_warp
|
||||
name: Time Warp
|
||||
description: Manipulate time itself, granting all allies bonus actions.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- mass_enhancement
|
||||
effects:
|
||||
abilities:
|
||||
- time_warp
|
||||
|
||||
- skill_id: master_weaver
|
||||
name: Master Weaver
|
||||
description: Your weaving expertise makes all enchantments far more potent.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- arcane_weakness
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 15
|
||||
charisma: 10
|
||||
combat_bonuses:
|
||||
buff_power: 0.35 # Additional +35% buff effectiveness
|
||||
debuff_power: 0.35 # +35% debuff effectiveness
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: reality_shift
|
||||
name: Reality Shift
|
||||
description: Shift reality to massively empower all allies and weaken all enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- time_warp
|
||||
effects:
|
||||
abilities:
|
||||
- reality_shift
|
||||
|
||||
- skill_id: archmage
|
||||
name: Archmage
|
||||
description: Achieve the rank of archmage. Incredible support magic bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- master_weaver
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 25
|
||||
charisma: 20
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
buff_power: 0.75 # Additional +75% buff effectiveness
|
||||
debuff_power: 0.75 # Additional +75% debuff effectiveness
|
||||
|
||||
# ==================== ILLUSIONIST (Crowd Control) ====================
|
||||
- tree_id: illusionist
|
||||
name: Illusionist
|
||||
description: >
|
||||
The path of deception. Master illusion magic to confuse, disorient, and control
|
||||
the minds of your enemies, rendering them helpless.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: confuse
|
||||
name: Confuse
|
||||
description: Confuse an enemy's mind, causing them to attack randomly for 2 turns.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- confuse
|
||||
|
||||
- skill_id: silver_tongue
|
||||
name: Silver Tongue
|
||||
description: Your persuasive abilities make mind magic more effective.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: mesmerize
|
||||
name: Mesmerize
|
||||
description: Mesmerize an enemy, stunning them for 2 turns.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- confuse
|
||||
effects:
|
||||
abilities:
|
||||
- mesmerize
|
||||
|
||||
- skill_id: mental_fortress
|
||||
name: Mental Fortress
|
||||
description: Fortify your mind and those of your allies against mental attacks.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- silver_tongue
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
mental_resistance: 0.25 # +25% resistance to mind effects
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: mass_confusion
|
||||
name: Mass Confusion
|
||||
description: Confuse all enemies, causing chaos on the battlefield.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- mesmerize
|
||||
effects:
|
||||
abilities:
|
||||
- mass_confusion
|
||||
|
||||
- skill_id: mirror_image
|
||||
name: Mirror Image
|
||||
description: Create illusory copies of yourself that absorb attacks.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- mental_fortress
|
||||
effects:
|
||||
abilities:
|
||||
- mirror_image
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: phantasmal_killer
|
||||
name: Phantasmal Killer
|
||||
description: Create a terrifying illusion that deals massive psychic damage and fears enemies.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- mass_confusion
|
||||
effects:
|
||||
abilities:
|
||||
- phantasmal_killer
|
||||
|
||||
- skill_id: master_illusionist
|
||||
name: Master Illusionist
|
||||
description: Your illusions become nearly indistinguishable from reality.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- mirror_image
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 15
|
||||
intelligence: 10
|
||||
combat_bonuses:
|
||||
illusion_duration: 2 # +2 turns to illusion effects
|
||||
cc_effectiveness: 0.35 # +35% crowd control effectiveness
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: mass_domination
|
||||
name: Mass Domination
|
||||
description: Dominate the minds of all enemies, forcing them to fight for you briefly.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- phantasmal_killer
|
||||
effects:
|
||||
abilities:
|
||||
- mass_domination
|
||||
|
||||
- skill_id: grand_illusionist
|
||||
name: Grand Illusionist
|
||||
description: Become a grand illusionist. Reality bends to your will.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- master_illusionist
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 30
|
||||
intelligence: 15
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
illusion_duration: 5 # Additional +5 turns to illusions
|
||||
cc_effectiveness: 0.75 # Additional +75% crowd control effectiveness
|
||||
mental_damage_bonus: 1.0 # +100% psychic damage
|
||||
266
api/app/data/classes/luminary.yaml
Normal file
266
api/app/data/classes/luminary.yaml
Normal file
@@ -0,0 +1,266 @@
|
||||
# Luminary - Holy Healer/DPS
|
||||
# Flexible hybrid class: Choose Divine Protection (healing/shields) or Radiant Judgment (holy damage)
|
||||
|
||||
class_id: luminary
|
||||
name: Luminary
|
||||
description: >
|
||||
A blessed warrior who channels divine power. Luminaries excel in healing and protection,
|
||||
capable of becoming a guardian angel for their allies or a righteous crusader smiting evil.
|
||||
Choose your calling: protect the innocent or judge the wicked.
|
||||
|
||||
# Base stats (total: 68)
|
||||
base_stats:
|
||||
strength: 9 # Below average physical power
|
||||
dexterity: 9 # Below average agility
|
||||
constitution: 11 # Above average endurance
|
||||
intelligence: 12 # Above average magical power
|
||||
wisdom: 14 # High perception/divine power
|
||||
charisma: 13 # Above average social
|
||||
|
||||
starting_equipment:
|
||||
- rusty_mace
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== DIVINE PROTECTION (Healing/Shields) ====================
|
||||
- tree_id: divine_protection
|
||||
name: Divine Protection
|
||||
description: >
|
||||
The path of the guardian. Channel divine energy to heal wounds, shield allies,
|
||||
and protect the vulnerable from harm.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: heal
|
||||
name: Heal
|
||||
description: Channel divine energy to restore an ally's health.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- heal
|
||||
|
||||
- skill_id: divine_grace
|
||||
name: Divine Grace
|
||||
description: Your connection to the divine enhances your wisdom and healing power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: holy_shield
|
||||
name: Holy Shield
|
||||
description: Grant an ally a protective barrier that absorbs damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- heal
|
||||
effects:
|
||||
abilities:
|
||||
- holy_shield
|
||||
|
||||
- skill_id: blessed_aura
|
||||
name: Blessed Aura
|
||||
description: Emit an aura that passively regenerates nearby allies' health each turn.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- divine_grace
|
||||
effects:
|
||||
passive_effects:
|
||||
- aura_healing # 5% max HP per turn to allies
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: mass_heal
|
||||
name: Mass Heal
|
||||
description: Channel divine power to heal all allies at once.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- holy_shield
|
||||
effects:
|
||||
abilities:
|
||||
- mass_heal
|
||||
|
||||
- skill_id: guardian_angel
|
||||
name: Guardian Angel
|
||||
description: Place a protective blessing on an ally that prevents their next death.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- blessed_aura
|
||||
effects:
|
||||
abilities:
|
||||
- guardian_angel
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: divine_intervention
|
||||
name: Divine Intervention
|
||||
description: Call upon divine power to fully heal an ally and remove all debuffs.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- mass_heal
|
||||
effects:
|
||||
abilities:
|
||||
- divine_intervention
|
||||
|
||||
- skill_id: sanctified
|
||||
name: Sanctified
|
||||
description: Your divine power reaches new heights, improving all healing.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- guardian_angel
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
healing_power: 0.25 # +25% healing
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: resurrection
|
||||
name: Resurrection
|
||||
description: Bring a fallen ally back to life with 50% health and mana.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- divine_intervention
|
||||
effects:
|
||||
abilities:
|
||||
- resurrection
|
||||
|
||||
- skill_id: beacon_of_hope
|
||||
name: Beacon of Hope
|
||||
description: You radiate divine energy. Massive wisdom and healing bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- sanctified
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 20
|
||||
charisma: 10
|
||||
combat_bonuses:
|
||||
healing_power: 0.50 # Additional +50% healing
|
||||
|
||||
# ==================== RADIANT JUDGMENT (Holy Damage) ====================
|
||||
- tree_id: radiant_judgment
|
||||
name: Radiant Judgment
|
||||
description: >
|
||||
The path of the crusader. Wield holy power as a weapon, smiting the wicked
|
||||
with radiant damage and divine wrath.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: smite
|
||||
name: Smite
|
||||
description: Strike an enemy with holy power, dealing radiant damage.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- smite
|
||||
|
||||
- skill_id: righteous_fury
|
||||
name: Righteous Fury
|
||||
description: Your righteous anger fuels your holy power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: holy_fire
|
||||
name: Holy Fire
|
||||
description: Burn an enemy with holy flames, dealing damage and reducing their healing received.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- smite
|
||||
effects:
|
||||
abilities:
|
||||
- holy_fire
|
||||
|
||||
- skill_id: zealot
|
||||
name: Zealot
|
||||
description: Your devotion to righteousness increases your damage against evil.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- righteous_fury
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
strength: 3
|
||||
combat_bonuses:
|
||||
holy_damage_bonus: 0.15 # +15% holy damage
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: consecration
|
||||
name: Consecration
|
||||
description: Consecrate the ground, dealing holy damage to enemies standing in it each turn.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- holy_fire
|
||||
effects:
|
||||
abilities:
|
||||
- consecration
|
||||
|
||||
- skill_id: divine_wrath
|
||||
name: Divine Wrath
|
||||
description: Channel pure divine fury into your attacks.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- zealot
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
holy_damage_bonus: 0.20 # Additional +20% holy damage
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: hammer_of_justice
|
||||
name: Hammer of Justice
|
||||
description: Summon a massive holy hammer to crush your foes, stunning and damaging them.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- consecration
|
||||
effects:
|
||||
abilities:
|
||||
- hammer_of_justice
|
||||
|
||||
- skill_id: crusader
|
||||
name: Crusader
|
||||
description: You become a true crusader, dealing devastating holy damage.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- divine_wrath
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
strength: 5
|
||||
combat_bonuses:
|
||||
holy_damage_bonus: 0.25 # Additional +25% holy damage
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: divine_storm
|
||||
name: Divine Storm
|
||||
description: Unleash a catastrophic storm of holy energy, damaging and stunning all enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- hammer_of_justice
|
||||
effects:
|
||||
abilities:
|
||||
- divine_storm
|
||||
|
||||
- skill_id: avatar_of_light
|
||||
name: Avatar of Light
|
||||
description: Become an avatar of divine light itself. Incredible holy damage bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- crusader
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 20
|
||||
strength: 10
|
||||
charisma: 10
|
||||
combat_bonuses:
|
||||
holy_damage_bonus: 0.50 # Additional +50% holy damage
|
||||
275
api/app/data/classes/necromancer.yaml
Normal file
275
api/app/data/classes/necromancer.yaml
Normal file
@@ -0,0 +1,275 @@
|
||||
# Necromancer - DoT/Summoner
|
||||
# Flexible hybrid class: Choose Dark Affliction (DoTs/debuffs) or Raise Dead (summon undead)
|
||||
|
||||
class_id: necromancer
|
||||
name: Necromancer
|
||||
description: >
|
||||
A master of death magic who manipulates life force and commands the undead. Necromancers
|
||||
excel in draining enemies over time or overwhelming foes with undead minions.
|
||||
Choose your dark art: curse your enemies or raise an army of the dead.
|
||||
|
||||
# Base stats (total: 65)
|
||||
base_stats:
|
||||
strength: 8 # Low physical power
|
||||
dexterity: 10 # Average agility
|
||||
constitution: 10 # Average endurance
|
||||
intelligence: 14 # High magical power
|
||||
wisdom: 11 # Above average perception
|
||||
charisma: 12 # Above average social (commands undead)
|
||||
|
||||
starting_equipment:
|
||||
- bone_wand
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== DARK AFFLICTION (DoTs/Debuffs) ====================
|
||||
- tree_id: dark_affliction
|
||||
name: Dark Affliction
|
||||
description: >
|
||||
The path of the curseweaver. Master dark magic to drain life, inflict agonizing
|
||||
curses, and watch enemies wither away over time.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: drain_life
|
||||
name: Drain Life
|
||||
description: Siphon life force from an enemy, damaging them and healing yourself.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- drain_life
|
||||
|
||||
- skill_id: dark_knowledge
|
||||
name: Dark Knowledge
|
||||
description: Study of forbidden arts enhances your dark magic power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: plague
|
||||
name: Plague
|
||||
description: Infect an enemy with disease that spreads to nearby foes, dealing damage over time.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- drain_life
|
||||
effects:
|
||||
abilities:
|
||||
- plague
|
||||
|
||||
- skill_id: soul_harvest
|
||||
name: Soul Harvest
|
||||
description: Absorb the life essence of dying enemies, increasing your power.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- dark_knowledge
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
combat_bonuses:
|
||||
lifesteal: 0.15 # Heal for 15% of damage dealt
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: curse_of_agony
|
||||
name: Curse of Agony
|
||||
description: Curse an enemy with excruciating pain, dealing heavy damage over 5 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- plague
|
||||
effects:
|
||||
abilities:
|
||||
- curse_of_agony
|
||||
|
||||
- skill_id: dark_empowerment
|
||||
name: Dark Empowerment
|
||||
description: Channel dark energy to enhance all damage over time effects.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- soul_harvest
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 10
|
||||
combat_bonuses:
|
||||
dot_damage_bonus: 0.30 # +30% DoT damage
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: soul_rot
|
||||
name: Soul Rot
|
||||
description: Rot an enemy's soul, dealing massive damage over time and reducing their healing.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- curse_of_agony
|
||||
effects:
|
||||
abilities:
|
||||
- soul_rot
|
||||
|
||||
- skill_id: death_mastery
|
||||
name: Death Mastery
|
||||
description: Master the art of death magic, dramatically increasing curse potency.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- dark_empowerment
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 15
|
||||
combat_bonuses:
|
||||
dot_damage_bonus: 0.40 # Additional +40% DoT damage
|
||||
lifesteal: 0.15 # Additional +15% lifesteal
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: epidemic
|
||||
name: Epidemic
|
||||
description: Unleash a deadly epidemic that afflicts all enemies with multiple DoTs.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- soul_rot
|
||||
effects:
|
||||
abilities:
|
||||
- epidemic
|
||||
|
||||
- skill_id: lord_of_decay
|
||||
name: Lord of Decay
|
||||
description: Become a lord of death and decay. Incredible DoT and drain bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- death_mastery
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 25
|
||||
wisdom: 15
|
||||
combat_bonuses:
|
||||
dot_damage_bonus: 1.0 # Additional +100% DoT damage
|
||||
lifesteal: 0.30 # Additional +30% lifesteal
|
||||
|
||||
# ==================== RAISE DEAD (Summon Undead) ====================
|
||||
- tree_id: raise_dead
|
||||
name: Raise Dead
|
||||
description: >
|
||||
The path of the necromancer. Command armies of the undead, raising fallen
|
||||
enemies and empowering your minions with dark magic.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: summon_skeleton
|
||||
name: Summon Skeleton
|
||||
description: Raise a skeleton warrior from the ground to fight for you.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- summon_skeleton
|
||||
|
||||
- skill_id: dark_command
|
||||
name: Dark Command
|
||||
description: Your mastery over the undead makes your minions stronger.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 5
|
||||
combat_bonuses:
|
||||
minion_damage_bonus: 0.15 # +15% minion damage
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: raise_ghoul
|
||||
name: Raise Ghoul
|
||||
description: Summon a ravenous ghoul that deals heavy melee damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- summon_skeleton
|
||||
effects:
|
||||
abilities:
|
||||
- raise_ghoul
|
||||
|
||||
- skill_id: unholy_bond
|
||||
name: Unholy Bond
|
||||
description: Strengthen your connection to the undead, empowering your minions.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- dark_command
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 5
|
||||
combat_bonuses:
|
||||
minion_damage_bonus: 0.20 # Additional +20% minion damage
|
||||
minion_health_bonus: 0.25 # +25% minion HP
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: corpse_explosion
|
||||
name: Corpse Explosion
|
||||
description: Detonate a corpse or minion, dealing massive AoE damage.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- raise_ghoul
|
||||
effects:
|
||||
abilities:
|
||||
- corpse_explosion
|
||||
|
||||
- skill_id: death_pact
|
||||
name: Death Pact
|
||||
description: Sacrifice a minion to restore your health and mana.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- unholy_bond
|
||||
effects:
|
||||
abilities:
|
||||
- death_pact
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: summon_abomination
|
||||
name: Summon Abomination
|
||||
description: Raise a massive undead abomination that dominates the battlefield.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- corpse_explosion
|
||||
effects:
|
||||
abilities:
|
||||
- summon_abomination
|
||||
|
||||
- skill_id: legion_master
|
||||
name: Legion Master
|
||||
description: Command larger armies of undead with increased effectiveness.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- death_pact
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 15
|
||||
intelligence: 5
|
||||
combat_bonuses:
|
||||
minion_damage_bonus: 0.35 # Additional +35% minion damage
|
||||
minion_health_bonus: 0.50 # Additional +50% minion HP
|
||||
max_minions: 2 # +2 max minions
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: army_of_the_dead
|
||||
name: Army of the Dead
|
||||
description: Summon a massive army of undead warriors to overwhelm your enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- summon_abomination
|
||||
effects:
|
||||
abilities:
|
||||
- army_of_the_dead
|
||||
|
||||
- skill_id: lich_lord
|
||||
name: Lich Lord
|
||||
description: Transcend mortality to become a lich lord. Incredible minion bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- legion_master
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 25
|
||||
intelligence: 20
|
||||
combat_bonuses:
|
||||
minion_damage_bonus: 1.0 # Additional +100% minion damage
|
||||
minion_health_bonus: 1.0 # Additional +100% minion HP
|
||||
max_minions: 3 # Additional +3 max minions
|
||||
265
api/app/data/classes/oathkeeper.yaml
Normal file
265
api/app/data/classes/oathkeeper.yaml
Normal file
@@ -0,0 +1,265 @@
|
||||
# Oathkeeper - Hybrid Tank/Healer
|
||||
# Flexible hybrid class: Choose Aegis of Light (protection/tanking) or Redemption (healing/support)
|
||||
|
||||
class_id: oathkeeper
|
||||
name: Oathkeeper
|
||||
description: >
|
||||
A sacred warrior bound by holy oaths. Oathkeepers excel as versatile protectors,
|
||||
capable of becoming an unyielding shield for their allies or a beacon of healing light.
|
||||
Choose your oath: defend the weak or redeem the fallen.
|
||||
|
||||
# Base stats (total: 67)
|
||||
base_stats:
|
||||
strength: 12 # Above average physical power
|
||||
dexterity: 9 # Below average agility
|
||||
constitution: 13 # High endurance
|
||||
intelligence: 10 # Average magic
|
||||
wisdom: 12 # Above average perception
|
||||
charisma: 11 # Above average social
|
||||
|
||||
starting_equipment:
|
||||
- rusty_sword
|
||||
- rusty_shield
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== AEGIS OF LIGHT (Protection/Tanking) ====================
|
||||
- tree_id: aegis_of_light
|
||||
name: Aegis of Light
|
||||
description: >
|
||||
The path of the protector. Become an unyielding guardian who shields allies
|
||||
from harm and draws enemy attention through divine resilience.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: taunt
|
||||
name: Taunt
|
||||
description: Challenge enemies to attack you instead of your allies.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- taunt
|
||||
|
||||
- skill_id: blessed_armor
|
||||
name: Blessed Armor
|
||||
description: Divine power enhances your natural toughness.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: shield_of_faith
|
||||
name: Shield of Faith
|
||||
description: Conjure a holy shield that absorbs damage for you and nearby allies.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- taunt
|
||||
effects:
|
||||
abilities:
|
||||
- shield_of_faith
|
||||
|
||||
- skill_id: sacred_resilience
|
||||
name: Sacred Resilience
|
||||
description: Your oath grants you resistance to harm.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- blessed_armor
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 5
|
||||
resistance: 5
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: consecrated_ground
|
||||
name: Consecrated Ground
|
||||
description: Bless the ground beneath you, providing damage reduction to allies standing in it.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- shield_of_faith
|
||||
effects:
|
||||
abilities:
|
||||
- consecrated_ground
|
||||
|
||||
- skill_id: unbreakable_oath
|
||||
name: Unbreakable Oath
|
||||
description: Your oath makes you incredibly difficult to bring down.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- sacred_resilience
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 10
|
||||
defense: 10
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: divine_aegis
|
||||
name: Divine Aegis
|
||||
description: Summon a massive divine shield that protects all allies for 3 turns.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- consecrated_ground
|
||||
effects:
|
||||
abilities:
|
||||
- divine_aegis
|
||||
|
||||
- skill_id: indomitable
|
||||
name: Indomitable
|
||||
description: Nothing can break your will or body. Massive defensive bonuses.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- unbreakable_oath
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 15
|
||||
defense: 15
|
||||
combat_bonuses:
|
||||
damage_reduction: 0.15 # Reduce all damage by 15%
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: last_stand
|
||||
name: Last Stand
|
||||
description: Become invulnerable for 3 turns while taunting all enemies. Cannot be canceled.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- divine_aegis
|
||||
effects:
|
||||
abilities:
|
||||
- last_stand
|
||||
|
||||
- skill_id: eternal_guardian
|
||||
name: Eternal Guardian
|
||||
description: You are an eternal bastion of protection. Incredible defensive bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- indomitable
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 25
|
||||
defense: 20
|
||||
resistance: 15
|
||||
combat_bonuses:
|
||||
damage_reduction: 0.30 # Additional 30% damage reduction
|
||||
|
||||
# ==================== REDEMPTION (Healing/Support) ====================
|
||||
- tree_id: redemption
|
||||
name: Redemption
|
||||
description: >
|
||||
The path of the redeemer. Channel divine power to heal wounds, cleanse corruption,
|
||||
and grant your allies second chances through sacred intervention.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: lay_on_hands
|
||||
name: Lay on Hands
|
||||
description: Touch an ally to restore their health through divine power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- lay_on_hands
|
||||
|
||||
- skill_id: divine_wisdom
|
||||
name: Divine Wisdom
|
||||
description: Your wisdom grows through devotion to your oath.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: cleanse
|
||||
name: Cleanse
|
||||
description: Remove all debuffs and negative effects from an ally.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- lay_on_hands
|
||||
effects:
|
||||
abilities:
|
||||
- cleanse
|
||||
|
||||
- skill_id: aura_of_mercy
|
||||
name: Aura of Mercy
|
||||
description: Emit a merciful aura that slowly heals all nearby allies each turn.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- divine_wisdom
|
||||
effects:
|
||||
passive_effects:
|
||||
- healing_aura # 3% max HP per turn
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: word_of_healing
|
||||
name: Word of Healing
|
||||
description: Speak a divine word that heals all allies within range.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- cleanse
|
||||
effects:
|
||||
abilities:
|
||||
- word_of_healing
|
||||
|
||||
- skill_id: blessed_sacrifice
|
||||
name: Blessed Sacrifice
|
||||
description: Transfer an ally's wounds to yourself, healing them while you take damage.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- aura_of_mercy
|
||||
effects:
|
||||
abilities:
|
||||
- blessed_sacrifice
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: divine_blessing
|
||||
name: Divine Blessing
|
||||
description: Grant an ally a powerful blessing that increases their stats and regenerates health.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- word_of_healing
|
||||
effects:
|
||||
abilities:
|
||||
- divine_blessing
|
||||
|
||||
- skill_id: martyr
|
||||
name: Martyr
|
||||
description: Your willingness to sacrifice yourself empowers your healing abilities.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- blessed_sacrifice
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 15
|
||||
combat_bonuses:
|
||||
healing_power: 0.35 # +35% healing
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: miracle
|
||||
name: Miracle
|
||||
description: Perform a divine miracle, fully healing all allies and removing all debuffs.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- divine_blessing
|
||||
effects:
|
||||
abilities:
|
||||
- miracle
|
||||
|
||||
- skill_id: sainthood
|
||||
name: Sainthood
|
||||
description: Achieve sainthood through your devotion. Incredible healing and support bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- martyr
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 25
|
||||
charisma: 15
|
||||
combat_bonuses:
|
||||
healing_power: 0.75 # Additional +75% healing
|
||||
aura_radius: 2 # Increase aura range
|
||||
264
api/app/data/classes/vanguard.yaml
Normal file
264
api/app/data/classes/vanguard.yaml
Normal file
@@ -0,0 +1,264 @@
|
||||
# Vanguard - Melee Tank/DPS
|
||||
# Flexible hybrid class: Choose Shield Bearer (tank) or Weapon Master (DPS)
|
||||
|
||||
class_id: vanguard
|
||||
name: Vanguard
|
||||
description: >
|
||||
A seasoned warrior who stands at the front lines of battle. Vanguards excel in melee combat,
|
||||
capable of becoming an unbreakable shield for their allies or a relentless damage dealer.
|
||||
Choose your path: become a stalwart defender or a devastating weapon master.
|
||||
|
||||
# Base stats (total: 65, average: 10.83)
|
||||
base_stats:
|
||||
strength: 14 # High physical power
|
||||
dexterity: 10 # Average agility
|
||||
constitution: 14 # High endurance for tanking
|
||||
intelligence: 8 # Low magic
|
||||
wisdom: 10 # Average perception
|
||||
charisma: 9 # Below average social
|
||||
|
||||
# Starting equipment (minimal)
|
||||
starting_equipment:
|
||||
- rusty_sword
|
||||
- cloth_armor
|
||||
- rusty_knife # Everyone gets pocket knife
|
||||
|
||||
# Starting abilities
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
# Skill trees (mutually exclusive playstyles)
|
||||
skill_trees:
|
||||
# ==================== SHIELD BEARER (Tank) ====================
|
||||
- tree_id: shield_bearer
|
||||
name: Shield Bearer
|
||||
description: >
|
||||
The path of the defender. Master the shield to become an impenetrable fortress,
|
||||
protecting your allies and controlling the battlefield.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: shield_bash
|
||||
name: Shield Bash
|
||||
description: Strike an enemy with your shield, dealing minor damage and stunning them for 1 turn.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- shield_bash # References ability YAML
|
||||
|
||||
- skill_id: fortify
|
||||
name: Fortify
|
||||
description: Your defensive training grants you enhanced protection.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
defense: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: shield_wall
|
||||
name: Shield Wall
|
||||
description: Raise your shield to block incoming attacks, reducing damage by 50% for 3 turns.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- shield_bash
|
||||
effects:
|
||||
abilities:
|
||||
- shield_wall
|
||||
|
||||
- skill_id: iron_skin
|
||||
name: Iron Skin
|
||||
description: Your body becomes hardened through relentless training.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- fortify
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 5
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: guardians_resolve
|
||||
name: Guardian's Resolve
|
||||
description: Your unwavering determination makes you nearly impossible to break.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- shield_wall
|
||||
effects:
|
||||
stat_bonuses:
|
||||
defense: 10
|
||||
passive_effects:
|
||||
- stun_resistance # Immune to stun when shield wall active
|
||||
|
||||
- skill_id: riposte
|
||||
name: Riposte
|
||||
description: After blocking an attack, counter with a swift strike.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- iron_skin
|
||||
effects:
|
||||
abilities:
|
||||
- riposte
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: bulwark
|
||||
name: Bulwark
|
||||
description: You are a living fortress, shrugging off blows that would fell lesser warriors.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- guardians_resolve
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 10
|
||||
resistance: 5
|
||||
|
||||
- skill_id: counter_strike
|
||||
name: Counter Strike
|
||||
description: Enhance your Riposte ability to deal critical damage when countering.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- riposte
|
||||
effects:
|
||||
ability_enhancements:
|
||||
riposte:
|
||||
crit_chance_bonus: 0.3 # +30% crit on riposte
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: unbreakable
|
||||
name: Unbreakable
|
||||
description: Channel your inner strength to become invulnerable, reducing all damage by 75% for 5 turns.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- bulwark
|
||||
effects:
|
||||
abilities:
|
||||
- unbreakable # Ultimate defensive ability
|
||||
|
||||
- skill_id: fortress
|
||||
name: Fortress
|
||||
description: Your defensive mastery reaches its peak. Permanently gain massive defensive bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- counter_strike
|
||||
effects:
|
||||
stat_bonuses:
|
||||
defense: 20
|
||||
constitution: 10
|
||||
resistance: 10
|
||||
|
||||
# ==================== WEAPON MASTER (DPS) ====================
|
||||
- tree_id: weapon_master
|
||||
name: Weapon Master
|
||||
description: >
|
||||
The path of destruction. Master devastating melee techniques to cut through enemies
|
||||
with overwhelming physical power.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: power_strike
|
||||
name: Power Strike
|
||||
description: A heavy attack that deals 150% weapon damage.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- power_strike
|
||||
|
||||
- skill_id: weapon_proficiency
|
||||
name: Weapon Proficiency
|
||||
description: Your training with weapons grants increased physical power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: cleave
|
||||
name: Cleave
|
||||
description: Swing your weapon in a wide arc, hitting all enemies in front of you.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- power_strike
|
||||
effects:
|
||||
abilities:
|
||||
- cleave # AoE attack
|
||||
|
||||
- skill_id: battle_frenzy
|
||||
name: Battle Frenzy
|
||||
description: The heat of battle fuels your strength.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- weapon_proficiency
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 5
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: rending_blow
|
||||
name: Rending Blow
|
||||
description: Strike with such force that your enemy bleeds for 3 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- cleave
|
||||
effects:
|
||||
abilities:
|
||||
- rending_blow # Applies bleed DoT
|
||||
|
||||
- skill_id: brutal_force
|
||||
name: Brutal Force
|
||||
description: Your attacks become devastatingly powerful.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- battle_frenzy
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 10
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: execute
|
||||
name: Execute
|
||||
description: Finish off weakened enemies. Deals bonus damage to targets below 30% HP.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- rending_blow
|
||||
effects:
|
||||
abilities:
|
||||
- execute
|
||||
|
||||
- skill_id: weapon_mastery
|
||||
name: Weapon Mastery
|
||||
description: Your expertise with weapons allows you to find weak points more easily.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- brutal_force
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 5
|
||||
combat_bonuses:
|
||||
crit_chance: 0.15 # +15% crit chance
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: titans_wrath
|
||||
name: Titan's Wrath
|
||||
description: Unleash a devastating attack that deals 300% weapon damage and stuns all enemies hit.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- execute
|
||||
effects:
|
||||
abilities:
|
||||
- titans_wrath # Ultimate offensive ability
|
||||
|
||||
- skill_id: perfect_form
|
||||
name: Perfect Form
|
||||
description: Your combat technique reaches perfection. Massive offensive bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- weapon_mastery
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 20
|
||||
dexterity: 10
|
||||
combat_bonuses:
|
||||
crit_chance: 0.1 # Additional +10% crit
|
||||
crit_multiplier: 0.5 # +0.5 to crit multiplier
|
||||
275
api/app/data/classes/wildstrider.yaml
Normal file
275
api/app/data/classes/wildstrider.yaml
Normal file
@@ -0,0 +1,275 @@
|
||||
# Wildstrider - Ranged Physical
|
||||
# Flexible hybrid class: Choose Marksmanship (precision ranged) or Beast Companion (pet damage)
|
||||
|
||||
class_id: wildstrider
|
||||
name: Wildstrider
|
||||
description: >
|
||||
A master of the wilds who excels at ranged combat and bonds with nature. Wildstriders
|
||||
can become elite marksmen with unmatched accuracy or beast masters commanding powerful
|
||||
animal companions. Choose your path: perfect your aim or unleash the wild.
|
||||
|
||||
# Base stats (total: 66)
|
||||
base_stats:
|
||||
strength: 10 # Average physical power
|
||||
dexterity: 14 # High agility
|
||||
constitution: 11 # Above average endurance
|
||||
intelligence: 9 # Below average magic
|
||||
wisdom: 13 # Above average perception
|
||||
charisma: 9 # Below average social
|
||||
|
||||
starting_equipment:
|
||||
- rusty_bow
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== MARKSMANSHIP (Precision Ranged) ====================
|
||||
- tree_id: marksmanship
|
||||
name: Marksmanship
|
||||
description: >
|
||||
The path of the sharpshooter. Master the bow to deliver devastating precision
|
||||
strikes from afar, never missing your mark.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: aimed_shot
|
||||
name: Aimed Shot
|
||||
description: Take careful aim before firing, dealing increased damage with high accuracy.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- aimed_shot
|
||||
|
||||
- skill_id: steady_hand
|
||||
name: Steady Hand
|
||||
description: Your ranged accuracy improves through training.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: multishot
|
||||
name: Multishot
|
||||
description: Fire multiple arrows at once, hitting up to 3 targets.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- aimed_shot
|
||||
effects:
|
||||
abilities:
|
||||
- multishot
|
||||
|
||||
- skill_id: eagle_eye
|
||||
name: Eagle Eye
|
||||
description: Your perception sharpens, increasing critical hit chance with ranged weapons.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- steady_hand
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
ranged_crit_chance: 0.15 # +15% crit with ranged
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: piercing_shot
|
||||
name: Piercing Shot
|
||||
description: Fire an arrow that pierces through enemies, hitting all in a line.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- multishot
|
||||
effects:
|
||||
abilities:
|
||||
- piercing_shot
|
||||
|
||||
- skill_id: deadly_aim
|
||||
name: Deadly Aim
|
||||
description: Your arrows find vital spots with deadly precision.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- eagle_eye
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 10
|
||||
combat_bonuses:
|
||||
ranged_crit_chance: 0.10 # Additional +10% crit
|
||||
ranged_crit_multiplier: 0.5 # +0.5 to crit damage
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: explosive_shot
|
||||
name: Explosive Shot
|
||||
description: Fire an explosive arrow that detonates on impact, damaging nearby enemies.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- piercing_shot
|
||||
effects:
|
||||
abilities:
|
||||
- explosive_shot
|
||||
|
||||
- skill_id: master_archer
|
||||
name: Master Archer
|
||||
description: Your archery skills reach legendary status.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- deadly_aim
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 15
|
||||
combat_bonuses:
|
||||
ranged_damage_bonus: 0.25 # +25% ranged damage
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: rain_of_arrows
|
||||
name: Rain of Arrows
|
||||
description: Fire countless arrows into the sky that rain down on all enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- explosive_shot
|
||||
effects:
|
||||
abilities:
|
||||
- rain_of_arrows
|
||||
|
||||
- skill_id: true_shot
|
||||
name: True Shot
|
||||
description: Every arrow finds its mark. Massive ranged combat bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- master_archer
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 20
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
ranged_damage_bonus: 0.50 # Additional +50% ranged damage
|
||||
ranged_crit_chance: 0.20 # Additional +20% crit
|
||||
|
||||
# ==================== BEAST COMPANION (Pet Damage) ====================
|
||||
- tree_id: beast_companion
|
||||
name: Beast Companion
|
||||
description: >
|
||||
The path of the beast master. Bond with a wild animal companion that fights
|
||||
alongside you, growing stronger as your connection deepens.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: summon_companion
|
||||
name: Summon Companion
|
||||
description: Call a loyal animal companion to fight by your side.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- summon_companion
|
||||
|
||||
- skill_id: animal_bond
|
||||
name: Animal Bond
|
||||
description: Your connection with nature strengthens your companion.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 0.15 # +15% pet damage
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: coordinated_attack
|
||||
name: Coordinated Attack
|
||||
description: Command your companion to attack with you for combined damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- summon_companion
|
||||
effects:
|
||||
abilities:
|
||||
- coordinated_attack
|
||||
|
||||
- skill_id: feral_instinct
|
||||
name: Feral Instinct
|
||||
description: Your companion becomes more ferocious and resilient.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- animal_bond
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 0.20 # Additional +20% pet damage
|
||||
pet_health_bonus: 0.25 # +25% pet HP
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: bestial_wrath
|
||||
name: Bestial Wrath
|
||||
description: Enrage your companion, drastically increasing its damage for 3 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- coordinated_attack
|
||||
effects:
|
||||
abilities:
|
||||
- bestial_wrath
|
||||
|
||||
- skill_id: wild_empathy
|
||||
name: Wild Empathy
|
||||
description: Your bond with beasts allows your companion to grow stronger.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- feral_instinct
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 0.25 # Additional +25% pet damage
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: primal_fury
|
||||
name: Primal Fury
|
||||
description: Your companion enters a primal rage, dealing massive damage to all enemies.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- bestial_wrath
|
||||
effects:
|
||||
abilities:
|
||||
- primal_fury
|
||||
|
||||
- skill_id: apex_predator
|
||||
name: Apex Predator
|
||||
description: Your companion becomes an apex predator, feared by all.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- wild_empathy
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 0.35 # Additional +35% pet damage
|
||||
pet_health_bonus: 0.50 # Additional +50% pet HP
|
||||
pet_crit_chance: 0.20 # +20% pet crit
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: stampede
|
||||
name: Stampede
|
||||
description: Summon a stampede of beasts that trample all enemies, dealing catastrophic damage.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- primal_fury
|
||||
effects:
|
||||
abilities:
|
||||
- stampede
|
||||
|
||||
- skill_id: one_with_the_wild
|
||||
name: One with the Wild
|
||||
description: You and your companion become one with nature. Incredible bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- apex_predator
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 20
|
||||
dexterity: 10
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 1.0 # Additional +100% pet damage (double damage!)
|
||||
pet_health_bonus: 1.0 # Additional +100% pet HP
|
||||
269
api/app/data/generic_items.yaml
Normal file
269
api/app/data/generic_items.yaml
Normal file
@@ -0,0 +1,269 @@
|
||||
# Generic Item Templates
|
||||
# These are common mundane items that the AI can give to players during gameplay.
|
||||
# They serve as templates for AI-generated items, providing consistent values
|
||||
# for simple items like torches, food, rope, etc.
|
||||
#
|
||||
# When the AI creates a generic item, the validator will look for a matching
|
||||
# template to use as defaults. Items not matching a template will be created
|
||||
# with the AI-provided values only.
|
||||
|
||||
templates:
|
||||
# Light sources
|
||||
torch:
|
||||
name: "Torch"
|
||||
description: "A wooden torch that provides light in dark places."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
lantern:
|
||||
name: "Lantern"
|
||||
description: "An oil lantern that provides steady light."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
candle:
|
||||
name: "Candle"
|
||||
description: "A simple wax candle."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Food and drink
|
||||
bread:
|
||||
name: "Bread"
|
||||
description: "A loaf of bread, possibly stale but still edible."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
apple:
|
||||
name: "Apple"
|
||||
description: "A fresh red apple."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
cheese:
|
||||
name: "Cheese"
|
||||
description: "A wedge of aged cheese."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
rations:
|
||||
name: "Rations"
|
||||
description: "A day's worth of preserved food."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
water:
|
||||
name: "Waterskin"
|
||||
description: "A leather waterskin filled with clean water."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
ale:
|
||||
name: "Ale"
|
||||
description: "A mug of common tavern ale."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
wine:
|
||||
name: "Wine"
|
||||
description: "A bottle of wine."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Tools and supplies
|
||||
rope:
|
||||
name: "Rope"
|
||||
description: "A sturdy length of hempen rope, about 50 feet."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
flint:
|
||||
name: "Flint and Steel"
|
||||
description: "A flint and steel for starting fires."
|
||||
value: 3
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
bedroll:
|
||||
name: "Bedroll"
|
||||
description: "A simple bedroll for sleeping outdoors."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
backpack:
|
||||
name: "Backpack"
|
||||
description: "A sturdy canvas backpack."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
crowbar:
|
||||
name: "Crowbar"
|
||||
description: "An iron crowbar for prying things open."
|
||||
value: 8
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
hammer:
|
||||
name: "Hammer"
|
||||
description: "A simple hammer."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
pitons:
|
||||
name: "Pitons"
|
||||
description: "A set of iron pitons for climbing."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
grappling_hook:
|
||||
name: "Grappling Hook"
|
||||
description: "A three-pronged iron grappling hook."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Writing supplies
|
||||
ink:
|
||||
name: "Ink"
|
||||
description: "A small vial of black ink."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
parchment:
|
||||
name: "Parchment"
|
||||
description: "A sheet of parchment for writing."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
quill:
|
||||
name: "Quill"
|
||||
description: "A feather quill for writing."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Containers
|
||||
pouch:
|
||||
name: "Pouch"
|
||||
description: "A small leather pouch."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
sack:
|
||||
name: "Sack"
|
||||
description: "A burlap sack for carrying goods."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
vial:
|
||||
name: "Empty Vial"
|
||||
description: "A small glass vial."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Clothing
|
||||
cloak:
|
||||
name: "Cloak"
|
||||
description: "A simple traveler's cloak."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
boots:
|
||||
name: "Boots"
|
||||
description: "A sturdy pair of leather boots."
|
||||
value: 8
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
gloves:
|
||||
name: "Gloves"
|
||||
description: "A pair of leather gloves."
|
||||
value: 3
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Miscellaneous
|
||||
mirror:
|
||||
name: "Mirror"
|
||||
description: "A small steel mirror."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
bell:
|
||||
name: "Bell"
|
||||
description: "A small brass bell."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
whistle:
|
||||
name: "Whistle"
|
||||
description: "A simple wooden whistle."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
key:
|
||||
name: "Key"
|
||||
description: "A simple iron key."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
map:
|
||||
name: "Map"
|
||||
description: "A rough map of the local area."
|
||||
value: 15
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
compass:
|
||||
name: "Compass"
|
||||
description: "A magnetic compass for navigation."
|
||||
value: 20
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Simple consumables
|
||||
bandage:
|
||||
name: "Bandage"
|
||||
description: "A clean cloth bandage for basic wound care."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
antidote:
|
||||
name: "Antidote"
|
||||
description: "A basic herbal remedy for common poisons."
|
||||
value: 15
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
herbs:
|
||||
name: "Herbs"
|
||||
description: "A bundle of useful herbs."
|
||||
value: 3
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
46
api/app/data/locations/crossville/crossville_crypt.yaml
Normal file
46
api/app/data/locations/crossville/crossville_crypt.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# The Forgotten Crypt - Ancient burial site
|
||||
location_id: crossville_crypt
|
||||
name: The Forgotten Crypt
|
||||
location_type: ruins
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
Hidden beneath a collapsed stone circle deep in Thornwood Forest lies an
|
||||
ancient crypt. The entrance, half-buried by centuries of accumulated earth
|
||||
and roots, leads down into darkness. Faded carvings on the weathered stones
|
||||
depict figures in robes performing unknown rituals around what appears to be
|
||||
a great black sun.
|
||||
|
||||
lore: |
|
||||
Long before Crossville was founded, before even the elves came to these lands,
|
||||
another civilization built monuments to their strange gods. The Forgotten Crypt
|
||||
is one of their burial sites - a place where priest-kings were interred with
|
||||
their servants and treasures. Local legends warn that the dead here do not
|
||||
rest peacefully, and that disturbing their tombs invites a terrible curse.
|
||||
|
||||
ambient_description: |
|
||||
The air in the crypt is stale and cold, carrying the musty scent of ancient
|
||||
decay. What little light enters through the broken ceiling reveals dust motes
|
||||
floating in perfectly still air. Stone sarcophagi line the walls, their lids
|
||||
carved with the faces of the long-dead. Some lids have been displaced,
|
||||
revealing empty darkness within. The silence is absolute - even footsteps
|
||||
seem muffled, as if the crypt itself absorbs sound.
|
||||
|
||||
available_quests:
|
||||
- quest_undead_menace
|
||||
- quest_ancient_relic
|
||||
- quest_necromancer_lair
|
||||
|
||||
npc_ids: []
|
||||
|
||||
discoverable_locations: []
|
||||
|
||||
is_starting_location: false
|
||||
|
||||
tags:
|
||||
- ruins
|
||||
- dangerous
|
||||
- undead
|
||||
- treasure
|
||||
- mystery
|
||||
- boss
|
||||
47
api/app/data/locations/crossville/crossville_dungeon.yaml
Normal file
47
api/app/data/locations/crossville/crossville_dungeon.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# The Old Mines - Abandoned dungeon beneath the hills
|
||||
location_id: crossville_dungeon
|
||||
name: The Old Mines
|
||||
location_type: dungeon
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
A network of abandoned mine tunnels carved into the hills north of Crossville.
|
||||
The mines were sealed decades ago after a cave-in killed a dozen workers, but
|
||||
the entrance has recently been found open. Strange sounds echo from the depths,
|
||||
and the few who have ventured inside speak of unnatural creatures lurking in
|
||||
the darkness.
|
||||
|
||||
lore: |
|
||||
The mines were originally dug by dwarven prospectors seeking iron ore. They
|
||||
found more than iron - ancient carvings deep in the tunnels suggest something
|
||||
else was buried here long ago. The cave-in that sealed the mines was blamed
|
||||
on unstable rock, but survivors whispered of something awakening in the deep.
|
||||
The mine was sealed and the entrance forbidden, until recent earthquakes
|
||||
reopened the way.
|
||||
|
||||
ambient_description: |
|
||||
The mine entrance yawns like a mouth in the hillside, exhaling cold air that
|
||||
smells of wet stone and something older. Rotting timber supports the first
|
||||
few feet of tunnel, beyond which darkness swallows everything. Somewhere
|
||||
in the depths, water drips with metronomic regularity. Occasionally, other
|
||||
sounds echo up from below - scraping, shuffling, or what might be whispered
|
||||
voices.
|
||||
|
||||
available_quests:
|
||||
- quest_mine_exploration
|
||||
- quest_lost_miners
|
||||
- quest_ancient_artifact
|
||||
|
||||
npc_ids: []
|
||||
|
||||
discoverable_locations:
|
||||
- crossville_crypt
|
||||
|
||||
is_starting_location: false
|
||||
|
||||
tags:
|
||||
- dungeon
|
||||
- dangerous
|
||||
- combat
|
||||
- treasure
|
||||
- mystery
|
||||
45
api/app/data/locations/crossville/crossville_forest.yaml
Normal file
45
api/app/data/locations/crossville/crossville_forest.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# Thornwood Forest - Wilderness area east of village
|
||||
location_id: crossville_forest
|
||||
name: Thornwood Forest
|
||||
location_type: wilderness
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
A dense woodland stretching east from Crossville, named for the thorny
|
||||
undergrowth that makes travel off the main path treacherous. Ancient oaks
|
||||
and twisted pines block much of the sunlight, creating an perpetual twilight
|
||||
beneath the canopy. The eastern trade road cuts through here, though bandits
|
||||
have made it increasingly dangerous.
|
||||
|
||||
lore: |
|
||||
The Thornwood is said to be as old as the mountains themselves. Local legend
|
||||
speaks of an ancient elven settlement deep within the forest, though no one
|
||||
has found it in living memory. What is certain is that the forest hides many
|
||||
secrets - ancient ruins, hidden caves, and creatures that prefer darkness
|
||||
to light. Hunters know to return before nightfall.
|
||||
|
||||
ambient_description: |
|
||||
Shafts of pale light filter through the canopy, illuminating swirling motes
|
||||
of dust and pollen. The forest floor is carpeted with fallen leaves and
|
||||
treacherous roots. Birds call from the branches above, falling silent
|
||||
whenever something large moves through the underbrush. The air is thick
|
||||
with the scent of damp earth and decaying vegetation.
|
||||
|
||||
available_quests:
|
||||
- quest_bandit_camp
|
||||
- quest_herb_gathering
|
||||
- quest_lost_traveler
|
||||
|
||||
npc_ids: []
|
||||
|
||||
discoverable_locations:
|
||||
- crossville_dungeon
|
||||
- crossville_crypt
|
||||
|
||||
is_starting_location: false
|
||||
|
||||
tags:
|
||||
- wilderness
|
||||
- dangerous
|
||||
- exploration
|
||||
- hunting
|
||||
47
api/app/data/locations/crossville/crossville_tavern.yaml
Normal file
47
api/app/data/locations/crossville/crossville_tavern.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# The Rusty Anchor Tavern - Social hub of Crossville
|
||||
location_id: crossville_tavern
|
||||
name: The Rusty Anchor Tavern
|
||||
location_type: tavern
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
A weathered two-story establishment at the heart of Crossville Village.
|
||||
The wooden sign creaks in the wind, depicting a rusted ship's anchor - an
|
||||
odd choice for a landlocked village. Inside, travelers and locals alike
|
||||
gather around rough-hewn tables to share drinks, stories, and rumors.
|
||||
|
||||
lore: |
|
||||
Founded eighty years ago by a retired sailor named Captain Morgath, the
|
||||
Rusty Anchor has served as Crossville's social hub for generations.
|
||||
The captain's old anchor hangs above the fireplace, supposedly recovered
|
||||
from a shipwreck that cost him his crew. Many adventurers have planned
|
||||
expeditions over tankards of the house special - a dark ale brewed from
|
||||
a secret recipe the captain brought from the coast.
|
||||
|
||||
ambient_description: |
|
||||
The tavern interior is warm and dimly lit by oil lamps and the glow of
|
||||
the stone hearth. Pipe smoke hangs in lazy clouds above the regulars'
|
||||
corner. The air smells of ale, roasted meat, and wood polish. A bard
|
||||
occasionally plucks at a lute in the corner, though tonight the only
|
||||
music is the murmur of conversation and the crackle of the fire.
|
||||
|
||||
available_quests:
|
||||
- quest_cellar_rats
|
||||
- quest_missing_shipment
|
||||
|
||||
npc_ids:
|
||||
- npc_grom_ironbeard
|
||||
- npc_mira_swiftfoot
|
||||
|
||||
discoverable_locations:
|
||||
- crossville_dungeon
|
||||
- crossville_forest
|
||||
|
||||
is_starting_location: false
|
||||
|
||||
tags:
|
||||
- social
|
||||
- rest
|
||||
- rumors
|
||||
- merchant
|
||||
- information
|
||||
44
api/app/data/locations/crossville/crossville_village.yaml
Normal file
44
api/app/data/locations/crossville/crossville_village.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
# Crossville Village - The main settlement
|
||||
location_id: crossville_village
|
||||
name: Crossville Village
|
||||
location_type: town
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
A modest farming village built around a central square where several roads
|
||||
meet. Stone and timber buildings line the main street, with the mayor's
|
||||
manor overlooking the square from a small hill. Farmers sell produce at
|
||||
market stalls while merchants hawk wares from distant lands.
|
||||
|
||||
lore: |
|
||||
Founded two centuries ago by settlers from the eastern kingdoms, Crossville
|
||||
grew from a simple waystation into a thriving village. The original stone
|
||||
well in the center of the square is said to have been blessed by a traveling
|
||||
cleric, and the village has never suffered drought since.
|
||||
|
||||
ambient_description: |
|
||||
The village square bustles with activity - farmers haggling over prices,
|
||||
children running between market stalls, and the rhythmic clang of the
|
||||
blacksmith's hammer echoing from his forge. The smell of fresh bread
|
||||
drifts from the bakery, mixing with the earthier scents of livestock
|
||||
and hay.
|
||||
|
||||
available_quests:
|
||||
- quest_mayors_request
|
||||
- quest_missing_merchant
|
||||
|
||||
npc_ids:
|
||||
- npc_mayor_aldric
|
||||
- npc_blacksmith_hilda
|
||||
|
||||
discoverable_locations:
|
||||
- crossville_tavern
|
||||
- crossville_forest
|
||||
|
||||
is_starting_location: true
|
||||
|
||||
tags:
|
||||
- town
|
||||
- social
|
||||
- merchant
|
||||
- safe
|
||||
16
api/app/data/locations/regions/crossville.yaml
Normal file
16
api/app/data/locations/regions/crossville.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Crossville Region - Starting area for new adventurers
|
||||
region_id: crossville
|
||||
name: Crossville Province
|
||||
description: |
|
||||
A quiet farming province on the frontier of the kingdom. Crossville sits at
|
||||
the crossroads of several trade routes, making it a natural gathering point
|
||||
for travelers, merchants, and those seeking adventure. The village has
|
||||
prospered from this trade, though recent bandit activity has made the roads
|
||||
less safe than they once were.
|
||||
|
||||
location_ids:
|
||||
- crossville_village
|
||||
- crossville_tavern
|
||||
- crossville_forest
|
||||
- crossville_dungeon
|
||||
- crossville_crypt
|
||||
281
api/app/data/loot_tables.yaml
Normal file
281
api/app/data/loot_tables.yaml
Normal file
@@ -0,0 +1,281 @@
|
||||
# Loot Tables
|
||||
# Defines what items can be found when searching in different locations.
|
||||
# Items are referenced by their template key from generic_items.yaml.
|
||||
#
|
||||
# Rarity tiers determine selection based on check margin:
|
||||
# - common: margin < 5 (just barely passed)
|
||||
# - uncommon: margin 5-9 (solid success)
|
||||
# - rare: margin >= 10 (excellent roll)
|
||||
#
|
||||
# Gold ranges are also determined by margin.
|
||||
|
||||
# Default loot for unspecified locations
|
||||
default:
|
||||
common:
|
||||
- torch
|
||||
- flint
|
||||
- rope
|
||||
- rations
|
||||
uncommon:
|
||||
- lantern
|
||||
- crowbar
|
||||
- bandage
|
||||
- herbs
|
||||
rare:
|
||||
- compass
|
||||
- map
|
||||
- antidote
|
||||
gold:
|
||||
min: 1
|
||||
max: 10
|
||||
bonus_per_margin: 1 # Extra gold per margin point
|
||||
|
||||
# Forest/wilderness locations
|
||||
forest:
|
||||
common:
|
||||
- herbs
|
||||
- apple
|
||||
- flint
|
||||
- rope
|
||||
uncommon:
|
||||
- rations
|
||||
- antidote
|
||||
- bandage
|
||||
- water
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- grappling_hook
|
||||
gold:
|
||||
min: 0
|
||||
max: 5
|
||||
bonus_per_margin: 0
|
||||
|
||||
# Cave/dungeon locations
|
||||
cave:
|
||||
common:
|
||||
- torch
|
||||
- flint
|
||||
- rope
|
||||
- pitons
|
||||
uncommon:
|
||||
- lantern
|
||||
- crowbar
|
||||
- grappling_hook
|
||||
- bandage
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- key
|
||||
gold:
|
||||
min: 5
|
||||
max: 25
|
||||
bonus_per_margin: 2
|
||||
|
||||
dungeon:
|
||||
common:
|
||||
- torch
|
||||
- key
|
||||
- rope
|
||||
- bandage
|
||||
uncommon:
|
||||
- lantern
|
||||
- crowbar
|
||||
- antidote
|
||||
- map
|
||||
rare:
|
||||
- compass
|
||||
- grappling_hook
|
||||
- mirror
|
||||
gold:
|
||||
min: 10
|
||||
max: 50
|
||||
bonus_per_margin: 3
|
||||
|
||||
# Town/city locations
|
||||
town:
|
||||
common:
|
||||
- bread
|
||||
- apple
|
||||
- ale
|
||||
- candle
|
||||
uncommon:
|
||||
- cheese
|
||||
- wine
|
||||
- rations
|
||||
- parchment
|
||||
rare:
|
||||
- map
|
||||
- ink
|
||||
- quill
|
||||
gold:
|
||||
min: 2
|
||||
max: 15
|
||||
bonus_per_margin: 1
|
||||
|
||||
tavern:
|
||||
common:
|
||||
- bread
|
||||
- cheese
|
||||
- ale
|
||||
- candle
|
||||
uncommon:
|
||||
- wine
|
||||
- rations
|
||||
- water
|
||||
- key
|
||||
rare:
|
||||
- map
|
||||
- pouch
|
||||
- mirror
|
||||
gold:
|
||||
min: 3
|
||||
max: 20
|
||||
bonus_per_margin: 2
|
||||
|
||||
# Ruins/ancient locations
|
||||
ruins:
|
||||
common:
|
||||
- torch
|
||||
- parchment
|
||||
- vial
|
||||
- rope
|
||||
uncommon:
|
||||
- ink
|
||||
- quill
|
||||
- mirror
|
||||
- key
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- antidote
|
||||
gold:
|
||||
min: 10
|
||||
max: 40
|
||||
bonus_per_margin: 3
|
||||
|
||||
# Camp/outdoor locations
|
||||
camp:
|
||||
common:
|
||||
- rations
|
||||
- water
|
||||
- bedroll
|
||||
- flint
|
||||
uncommon:
|
||||
- rope
|
||||
- torch
|
||||
- bandage
|
||||
- sack
|
||||
rare:
|
||||
- lantern
|
||||
- backpack
|
||||
- map
|
||||
gold:
|
||||
min: 1
|
||||
max: 10
|
||||
bonus_per_margin: 1
|
||||
|
||||
# Merchant/shop locations
|
||||
shop:
|
||||
common:
|
||||
- pouch
|
||||
- sack
|
||||
- candle
|
||||
- parchment
|
||||
uncommon:
|
||||
- ink
|
||||
- quill
|
||||
- vial
|
||||
- key
|
||||
rare:
|
||||
- map
|
||||
- mirror
|
||||
- compass
|
||||
gold:
|
||||
min: 5
|
||||
max: 30
|
||||
bonus_per_margin: 2
|
||||
|
||||
# Road/path locations
|
||||
road:
|
||||
common:
|
||||
- rope
|
||||
- flint
|
||||
- water
|
||||
- bread
|
||||
uncommon:
|
||||
- bandage
|
||||
- rations
|
||||
- torch
|
||||
- boots
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- cloak
|
||||
gold:
|
||||
min: 1
|
||||
max: 15
|
||||
bonus_per_margin: 1
|
||||
|
||||
# Castle/fortress locations
|
||||
castle:
|
||||
common:
|
||||
- torch
|
||||
- candle
|
||||
- key
|
||||
- parchment
|
||||
uncommon:
|
||||
- lantern
|
||||
- ink
|
||||
- quill
|
||||
- mirror
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- crowbar
|
||||
gold:
|
||||
min: 15
|
||||
max: 60
|
||||
bonus_per_margin: 4
|
||||
|
||||
# Dock/port locations
|
||||
dock:
|
||||
common:
|
||||
- rope
|
||||
- water
|
||||
- rations
|
||||
- sack
|
||||
uncommon:
|
||||
- grappling_hook
|
||||
- lantern
|
||||
- map
|
||||
- flint
|
||||
rare:
|
||||
- compass
|
||||
- backpack
|
||||
- cloak
|
||||
gold:
|
||||
min: 5
|
||||
max: 25
|
||||
bonus_per_margin: 2
|
||||
|
||||
# Mine locations
|
||||
mine:
|
||||
common:
|
||||
- torch
|
||||
- pitons
|
||||
- rope
|
||||
- hammer
|
||||
uncommon:
|
||||
- lantern
|
||||
- crowbar
|
||||
- flint
|
||||
- bandage
|
||||
rare:
|
||||
- grappling_hook
|
||||
- map
|
||||
- key
|
||||
gold:
|
||||
min: 15
|
||||
max: 50
|
||||
bonus_per_margin: 3
|
||||
93
api/app/data/npcs/crossville/npc_blacksmith_hilda.yaml
Normal file
93
api/app/data/npcs/crossville/npc_blacksmith_hilda.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
# Hilda Ironforge - Village Blacksmith
|
||||
npc_id: npc_blacksmith_hilda
|
||||
name: Hilda Ironforge
|
||||
role: blacksmith
|
||||
location_id: crossville_village
|
||||
|
||||
personality:
|
||||
traits:
|
||||
- straightforward
|
||||
- hardworking
|
||||
- proud of her craft
|
||||
- protective of the village
|
||||
- stubborn as iron
|
||||
speech_style: |
|
||||
Blunt and direct - says what she means without flourish. Her voice
|
||||
carries the confidence of someone who knows their worth. Uses
|
||||
smithing metaphors often ("hammer out the details," "strike while
|
||||
hot"). Speaks slowly and deliberately, each word carrying weight.
|
||||
quirks:
|
||||
- Absent-mindedly hammers on things when thinking
|
||||
- Inspects every weapon she sees for quality
|
||||
- Refuses to sell poorly-made goods even at high prices
|
||||
- Hums dwarven work songs while forging
|
||||
|
||||
appearance:
|
||||
brief: Muscular dwarven woman with soot-streaked red hair, burn-scarred forearms, and an appraising gaze
|
||||
detailed: |
|
||||
Hilda is built like a forge - solid, hot-tempered, and productive.
|
||||
Her red hair is pulled back in a practical braid, streaked with
|
||||
grey and permanently dusted with soot. Her forearms are a map of
|
||||
old burns and calluses, badges of honor in her trade. She wears a
|
||||
leather apron over practical clothes, and her hands are never far
|
||||
from a hammer. Her eyes assess everything with the critical gaze of
|
||||
a master craftsman, always noting quality - or its lack.
|
||||
|
||||
knowledge:
|
||||
public:
|
||||
- The best iron ore came from the Old Mines before they were sealed
|
||||
- She can repair almost anything made of metal
|
||||
- Bandit attacks have increased demand for weapons
|
||||
- Her family has been smithing in Crossville for four generations
|
||||
secret:
|
||||
- Her grandfather forged something for the previous mayor - something that was buried
|
||||
- She has the original designs for that artifact
|
||||
- The ore in the mines had unusual properties - made metal stronger
|
||||
- She suspects the bandits are looking for her grandfather's work
|
||||
will_share_if:
|
||||
- condition: "interaction_count >= 4"
|
||||
reveals: "Mentions her grandfather worked on a special project for the mayor's family"
|
||||
- condition: "custom_flags.brought_quality_ore == true"
|
||||
reveals: "Shares that the mine ore was special - almost magical"
|
||||
- condition: "relationship_level >= 75"
|
||||
reveals: "Shows them her grandfather's old designs"
|
||||
- condition: "custom_flags.proved_worthy_warrior == true"
|
||||
reveals: "Offers to forge them something special if they find the right materials"
|
||||
|
||||
relationships:
|
||||
- npc_id: npc_grom_ironbeard
|
||||
attitude: friendly
|
||||
reason: Old drinking companions and fellow dwarves
|
||||
- npc_id: npc_mayor_aldric
|
||||
attitude: respectful but curious
|
||||
reason: The Thornwood family has secrets connected to her own
|
||||
|
||||
inventory_for_sale:
|
||||
- item: sword_iron
|
||||
price: 50
|
||||
- item: shield_iron
|
||||
price: 40
|
||||
- item: armor_chainmail
|
||||
price: 150
|
||||
- item: dagger_steel
|
||||
price: 25
|
||||
- item: repair_service
|
||||
price: 20
|
||||
|
||||
dialogue_hooks:
|
||||
greeting: "*sets down hammer* Something you need forged, or just looking?"
|
||||
farewell: "May your blade stay sharp and your armor hold."
|
||||
busy: "*keeps hammering* Talk while I work. Time is iron."
|
||||
quest_complete: "*nods approvingly* Fine work. You've got the heart of a warrior."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_ore_delivery
|
||||
- quest_equipment_repair
|
||||
|
||||
reveals_locations: []
|
||||
|
||||
tags:
|
||||
- merchant
|
||||
- quest_giver
|
||||
- craftsman
|
||||
- dwarf
|
||||
95
api/app/data/npcs/crossville/npc_grom_ironbeard.yaml
Normal file
95
api/app/data/npcs/crossville/npc_grom_ironbeard.yaml
Normal file
@@ -0,0 +1,95 @@
|
||||
# Grom Ironbeard - Tavern Bartender
|
||||
npc_id: npc_grom_ironbeard
|
||||
name: Grom Ironbeard
|
||||
role: bartender
|
||||
location_id: crossville_tavern
|
||||
|
||||
personality:
|
||||
traits:
|
||||
- gruff
|
||||
- secretly kind
|
||||
- protective of regulars
|
||||
- distrustful of strangers
|
||||
- nostalgic about his adventuring days
|
||||
speech_style: |
|
||||
Short, clipped sentences. Heavy dwarvish accent - often drops articles
|
||||
("Need a drink?" becomes "Need drink?"). Speaks in a gravelly baritone.
|
||||
Uses "lad" and "lass" frequently. Never raises his voice unless truly angry.
|
||||
quirks:
|
||||
- Polishes the same glass when nervous or thinking
|
||||
- Tugs his beard when considering something seriously
|
||||
- Refuses to serve anyone who insults his ale
|
||||
- Hums old mining songs when the tavern is quiet
|
||||
|
||||
appearance:
|
||||
brief: Stocky dwarf with a braided grey beard, one clouded eye, and arms like tree trunks
|
||||
detailed: |
|
||||
Standing barely four feet tall, Grom's broad shoulders and thick arms
|
||||
speak to decades of barrel-lifting and troublemaker-throwing. His grey
|
||||
beard is immaculately braided with copper rings passed down from his
|
||||
grandfather. A milky cataract clouds his left eye - a souvenir from his
|
||||
adventuring days - but his right eye misses nothing that happens in his
|
||||
tavern. His apron is always clean, though his hands bear the calluses
|
||||
of hard work.
|
||||
|
||||
knowledge:
|
||||
public:
|
||||
- Local gossip about Mayor Aldric raising taxes again
|
||||
- The road east through Thornwood has been plagued by bandits
|
||||
- A traveling merchant was asking about ancient ruins last week
|
||||
- The blacksmith Hilda needs more iron ore but the mines are sealed
|
||||
secret:
|
||||
- Hidden passage behind the wine barrels leads to old smuggling tunnels
|
||||
- The mayor is being blackmailed by someone - he's seen the letters
|
||||
- Knows the location of a legendary dwarven forge in the mountains
|
||||
- The cave-in in the mines wasn't natural - something broke through from below
|
||||
will_share_if:
|
||||
- condition: "interaction_count >= 3"
|
||||
reveals: "Mentions he used to be an adventurer who explored the Old Mines"
|
||||
- condition: "custom_flags.helped_with_rowdy_patrons == true"
|
||||
reveals: "Shows them the hidden passage behind the wine barrels"
|
||||
- condition: "relationship_level >= 70"
|
||||
reveals: "Confides about the mayor's blackmail situation"
|
||||
- condition: "relationship_level >= 85"
|
||||
reveals: "Shares the location of the dwarven forge"
|
||||
|
||||
relationships:
|
||||
- npc_id: npc_mayor_aldric
|
||||
attitude: distrustful
|
||||
reason: Mayor raised tavern taxes unfairly and seems nervous lately
|
||||
- npc_id: npc_mira_swiftfoot
|
||||
attitude: protective
|
||||
reason: She reminds him of his daughter who died young
|
||||
- npc_id: npc_blacksmith_hilda
|
||||
attitude: friendly
|
||||
reason: Fellow dwarf and drinking companion for decades
|
||||
|
||||
inventory_for_sale:
|
||||
- item: ale
|
||||
price: 2
|
||||
- item: dwarven_stout
|
||||
price: 5
|
||||
- item: meal_hearty
|
||||
price: 8
|
||||
- item: room_night
|
||||
price: 15
|
||||
- item: information_local
|
||||
price: 10
|
||||
|
||||
dialogue_hooks:
|
||||
greeting: "*grunts* What'll it be? And don't waste my time."
|
||||
farewell: "*nods* Don't cause trouble out there."
|
||||
busy: "Got thirsty folk to serve. Make it quick."
|
||||
quest_complete: "*actually smiles* Well done, lad. Drink's on the house."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_cellar_rats
|
||||
|
||||
reveals_locations:
|
||||
- crossville_dungeon
|
||||
|
||||
tags:
|
||||
- merchant
|
||||
- quest_giver
|
||||
- information_source
|
||||
- dwarf
|
||||
83
api/app/data/npcs/crossville/npc_mayor_aldric.yaml
Normal file
83
api/app/data/npcs/crossville/npc_mayor_aldric.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
# Mayor Aldric Thornwood - Village Leader
|
||||
npc_id: npc_mayor_aldric
|
||||
name: Mayor Aldric Thornwood
|
||||
role: mayor
|
||||
location_id: crossville_village
|
||||
|
||||
personality:
|
||||
traits:
|
||||
- outwardly confident
|
||||
- secretly terrified
|
||||
- genuinely cares about the village
|
||||
- increasingly desperate
|
||||
- hiding something significant
|
||||
speech_style: |
|
||||
Speaks with the practiced cadence of a politician - measured words,
|
||||
careful pauses for effect. His voice wavers slightly when stressed,
|
||||
and he has a habit of clearing his throat before difficult topics.
|
||||
Uses formal address even in casual conversation.
|
||||
quirks:
|
||||
- Constantly adjusts his mayoral chain of office
|
||||
- Glances at his manor when the Old Mines are mentioned
|
||||
- Keeps touching a ring on his left hand
|
||||
- Offers wine to guests but never drinks himself
|
||||
|
||||
appearance:
|
||||
brief: Tall, thin man with receding grey hair, worry lines, and expensive but slightly disheveled clothing
|
||||
detailed: |
|
||||
Mayor Aldric carries himself with the posture of authority, though
|
||||
lately that posture has developed a slight stoop. His grey hair,
|
||||
once meticulously combed, shows signs of distracted neglect. His
|
||||
clothes are fine but wrinkled, and dark circles under his eyes
|
||||
suggest many sleepless nights. The heavy gold chain of his office
|
||||
seems to weigh on him more than it should. His hands tremble
|
||||
slightly when he thinks no one is watching.
|
||||
|
||||
knowledge:
|
||||
public:
|
||||
- The village has prospered under his ten-year leadership
|
||||
- Taxes were raised to fund road repairs and militia expansion
|
||||
- He's offering a reward for clearing the bandit threat
|
||||
- The Old Mines are sealed for safety reasons
|
||||
secret:
|
||||
- He's being blackmailed by someone who knows about the mines
|
||||
- His grandfather found something in the mines that should stay buried
|
||||
- The blackmailer wants access to the crypt
|
||||
- He knows the earthquake that reopened the mines wasn't natural
|
||||
will_share_if:
|
||||
- condition: "relationship_level >= 60"
|
||||
reveals: "Admits the tax increase was forced by external pressure"
|
||||
- condition: "custom_flags.proved_trustworthy == true"
|
||||
reveals: "Confesses he's being blackmailed but won't say by whom"
|
||||
- condition: "relationship_level >= 80"
|
||||
reveals: "Shares his grandfather's journal about the mines"
|
||||
- condition: "custom_flags.defeated_blackmailer == true"
|
||||
reveals: "Reveals everything about what's buried in the crypt"
|
||||
|
||||
relationships:
|
||||
- npc_id: npc_grom_ironbeard
|
||||
attitude: guilty
|
||||
reason: Knows the tax increase hurt the tavern unfairly
|
||||
- npc_id: npc_blacksmith_hilda
|
||||
attitude: respectful
|
||||
reason: Her family has served the village for generations
|
||||
|
||||
inventory_for_sale: []
|
||||
|
||||
dialogue_hooks:
|
||||
greeting: "*straightens his chain* Ah, welcome to Crossville. How may I be of service?"
|
||||
farewell: "The village thanks you. May your roads be safe."
|
||||
busy: "*distracted* I have urgent matters to attend. Perhaps later?"
|
||||
quest_complete: "*genuine relief* You have done Crossville a great service."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_mayors_request
|
||||
- quest_bandit_threat
|
||||
|
||||
reveals_locations:
|
||||
- crossville_dungeon
|
||||
|
||||
tags:
|
||||
- quest_giver
|
||||
- authority
|
||||
- human
|
||||
90
api/app/data/npcs/crossville/npc_mira_swiftfoot.yaml
Normal file
90
api/app/data/npcs/crossville/npc_mira_swiftfoot.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
# Mira Swiftfoot - Rogue and Information Broker
|
||||
npc_id: npc_mira_swiftfoot
|
||||
name: Mira Swiftfoot
|
||||
role: rogue
|
||||
location_id: crossville_tavern
|
||||
|
||||
personality:
|
||||
traits:
|
||||
- curious
|
||||
- street-smart
|
||||
- morally flexible
|
||||
- loyal once trust is earned
|
||||
- haunted by her past
|
||||
speech_style: |
|
||||
Quick and clever, often speaking in half-sentences as if her mouth
|
||||
can't keep up with her racing thoughts. Uses thieves' cant occasionally.
|
||||
Tends to deflect personal questions with humor or questions of her own.
|
||||
Her voice drops to a whisper when sharing secrets.
|
||||
quirks:
|
||||
- Always sits with her back to the wall, facing the door
|
||||
- Fidgets with a coin, rolling it across her knuckles
|
||||
- Sizes up everyone who enters the tavern
|
||||
- Never drinks anything she didn't pour herself
|
||||
|
||||
appearance:
|
||||
brief: Slender half-elf with sharp green eyes, dark hair cut short, and fingers that never stop moving
|
||||
detailed: |
|
||||
Mira moves with the easy grace of someone used to slipping through
|
||||
shadows unnoticed. Her dark hair is cut practically short, framing
|
||||
an angular face with sharp green eyes that seem to catalog everything
|
||||
they see. She dresses in muted colors - browns and greys that blend
|
||||
into any crowd. A thin scar runs from her left ear to her jaw, and
|
||||
she wears leather bracers that probably hide more than calluses.
|
||||
|
||||
knowledge:
|
||||
public:
|
||||
- The bandits in Thornwood are more organized than simple thieves
|
||||
- There's a fence in the city who buys no-questions-asked
|
||||
- The mayor's been receiving mysterious visitors at night
|
||||
- Several people have gone missing in the forest lately
|
||||
secret:
|
||||
- The bandit leader is a former soldier named Kael
|
||||
- She knows a secret entrance to the crypt through the forest
|
||||
- The missing people were all asking about the Old Mines
|
||||
- She's actually running from a thieves' guild she betrayed
|
||||
will_share_if:
|
||||
- condition: "interaction_count >= 2"
|
||||
reveals: "Mentions the bandits seem to be searching for something specific"
|
||||
- condition: "custom_flags.shared_drink == true"
|
||||
reveals: "Admits she knows more about the forest than most"
|
||||
- condition: "relationship_level >= 65"
|
||||
reveals: "Reveals she knows a secret path to the crypt"
|
||||
- condition: "relationship_level >= 80"
|
||||
reveals: "Tells them about Kael and offers to help infiltrate the bandits"
|
||||
|
||||
relationships:
|
||||
- npc_id: npc_grom_ironbeard
|
||||
attitude: affectionate
|
||||
reason: He's the closest thing to family she has
|
||||
- npc_id: npc_mayor_aldric
|
||||
attitude: suspicious
|
||||
reason: Something about him doesn't add up
|
||||
|
||||
inventory_for_sale:
|
||||
- item: lockpick_set
|
||||
price: 25
|
||||
- item: rope_silk
|
||||
price: 15
|
||||
- item: map_local
|
||||
price: 20
|
||||
|
||||
dialogue_hooks:
|
||||
greeting: "*looks you over* New face. What brings you to our little crossroads?"
|
||||
farewell: "Watch your back out there. Trust me on that."
|
||||
busy: "*glances at the door* Not now. Later."
|
||||
quest_complete: "*grins* You've got potential. Stick around."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_bandit_camp
|
||||
|
||||
reveals_locations:
|
||||
- crossville_forest
|
||||
- crossville_crypt
|
||||
|
||||
tags:
|
||||
- information_source
|
||||
- merchant
|
||||
- quest_giver
|
||||
- rogue
|
||||
- half-elf
|
||||
158
api/app/data/origins.yaml
Normal file
158
api/app/data/origins.yaml
Normal file
@@ -0,0 +1,158 @@
|
||||
# Character Origin Stories
|
||||
# These are saved to the character and referenced by the AI DM throughout the game
|
||||
# to create personalized narrative experiences and quest hooks.
|
||||
|
||||
origins:
|
||||
soul_revenant:
|
||||
id: soul_revenant
|
||||
name: Soul Revenant
|
||||
description: |
|
||||
You died centuries ago, but death was not the end. Through dark magic, divine
|
||||
intervention, or a cosmic mistake, you have been returned to the world of the
|
||||
living. Your memories are fragmented—flashes of a life long past, faces you once
|
||||
knew now turned to dust, and deeds both noble and terrible that weigh upon your soul.
|
||||
|
||||
The world has changed beyond recognition. The kingdom you served no longer exists,
|
||||
the people you loved are gone, and the wrongs you committed—or suffered—can never
|
||||
be undone. Yet here you stand, given a second chance you never asked for.
|
||||
|
||||
You awaken in an ancient crypt, your body restored but your purpose unclear.
|
||||
Are you here to atone? To finish unfinished business? Or simply to understand
|
||||
why you were brought back?
|
||||
|
||||
starting_location:
|
||||
id: forgotten_crypt
|
||||
name: The Forgotten Crypt
|
||||
region: Shadowmere Necropolis
|
||||
description: Ancient burial grounds beneath twisted trees, where the veil between life and death grows thin
|
||||
|
||||
narrative_hooks:
|
||||
- Past lives and forgotten memories that surface during gameplay
|
||||
- NPCs or descendants related to your previous life
|
||||
- Unfinished business from centuries ago
|
||||
- Haunted by spectral visions or voices from the past
|
||||
- Divine or dark entities interested in your return
|
||||
- Questions about identity and purpose across lifetimes
|
||||
|
||||
starting_bonus:
|
||||
trait: Deathless Resolve
|
||||
description: You have walked through death itself. Fear holds less power over you.
|
||||
effect: +2 WIS, resistance to fear effects
|
||||
|
||||
memory_thief:
|
||||
id: memory_thief
|
||||
name: Memory Thief
|
||||
description: |
|
||||
You opened your eyes in an open field with no memory of who you are, where you
|
||||
came from, or how you got here. Your mind is a blank slate—no name, no past,
|
||||
no identity. Even your own face in a reflection seems like a stranger's.
|
||||
|
||||
The only thing you possess is an overwhelming sense that something was taken
|
||||
from you. Your memories weren't simply lost—they were stolen. By whom? Why?
|
||||
You don't know. But deep in your gut, you feel that discovering the truth might
|
||||
be more terrifying than living in ignorance.
|
||||
|
||||
As you wander the world, fragments occasionally surface—a fleeting image, a
|
||||
half-remembered name, a skill you didn't know you had. Are these clues to your
|
||||
real identity, or false memories planted by whoever stole your past?
|
||||
|
||||
One thing is certain: you must piece together who you were, even if you discover
|
||||
you were someone you'd rather not remember.
|
||||
|
||||
starting_location:
|
||||
id: thornfield_plains
|
||||
name: Thornfield Plains
|
||||
region: The Midlands
|
||||
description: Vast open grasslands where merchant roads cross, a place of new beginnings for the lost
|
||||
|
||||
narrative_hooks:
|
||||
- Gradual memory fragments revealed during key story moments
|
||||
- NPCs who seem to recognize you but you don't remember them
|
||||
- Clues to your stolen past hidden in the world
|
||||
- A mysterious organization or individual who took your memories
|
||||
- Skills or knowledge you possess without knowing why
|
||||
- Identity crisis as you discover who you might have been
|
||||
|
||||
starting_bonus:
|
||||
trait: Blank Slate
|
||||
description: Without a past to define you, you adapt quickly to new situations.
|
||||
effect: +1 to all stats, faster skill learning
|
||||
|
||||
shadow_apprentice:
|
||||
id: shadow_apprentice
|
||||
name: Shadow Apprentice
|
||||
description: |
|
||||
You were raised in darkness—literally and figuratively. From childhood, you were
|
||||
trained by a mysterious master who taught you the arts of stealth, deception,
|
||||
and survival in the underworld. You learned to move unseen, to read people's
|
||||
secrets in their eyes, and to trust no one.
|
||||
|
||||
Your master never revealed why they chose you, only that you had "potential."
|
||||
For years, you honed your skills in the shadows, taking on jobs that required
|
||||
discretion and ruthlessness. You became a weapon in your master's hands.
|
||||
|
||||
But recently, everything changed. Your master disappeared without a word, leaving
|
||||
you with only your training and a single cryptic message: "Trust no one. Not even me."
|
||||
|
||||
Now you walk alone, unsure if your master abandoned you, was captured, or is
|
||||
testing you one final time. The underworld you once navigated so confidently
|
||||
suddenly feels hostile and full of eyes watching from the darkness.
|
||||
|
||||
starting_location:
|
||||
id: shadowfen
|
||||
name: Shadowfen
|
||||
region: The Murkvale
|
||||
description: A misty swamp settlement where outlaws and exiles gather, hidden from the world's judging eyes
|
||||
|
||||
narrative_hooks:
|
||||
- The mysterious master and their true motivations
|
||||
- Dark organizations or guilds from your past
|
||||
- Moral dilemmas between loyalty and self-preservation
|
||||
- Rivals or enemies from your apprenticeship
|
||||
- Secrets your master never told you
|
||||
- The true reason you were chosen and trained
|
||||
|
||||
starting_bonus:
|
||||
trait: Trained in Shadows
|
||||
description: Your master taught you well. The darkness is your ally.
|
||||
effect: +2 DEX, +1 CHA, advantage on stealth checks in darkness
|
||||
|
||||
escaped_captive:
|
||||
id: escaped_captive
|
||||
name: The Escaped Captive
|
||||
description: |
|
||||
You were a prisoner at Ironpeak Pass, one of the most notorious holding facilities
|
||||
in the realm. How you ended up there, you remember all too well—whether you were
|
||||
guilty or innocent, the iron bars didn't care. Days blurred into weeks, weeks into
|
||||
months, and you felt yourself becoming just another forgotten soul.
|
||||
|
||||
But you refused to accept that fate. Through cunning, luck, or sheer desperation,
|
||||
you escaped. Now you stand on the other side of those mountain walls, breathing
|
||||
free air for the first time in what feels like forever.
|
||||
|
||||
Freedom tastes sweet, but it comes with a price. The authorities will be searching
|
||||
for you. Your face might be on wanted posters. Anyone who learns of your past might
|
||||
turn you in for a reward. You must build a new life while constantly looking over
|
||||
your shoulder.
|
||||
|
||||
Can you truly start fresh, or will your past always define you? That depends on
|
||||
the choices you make from here.
|
||||
|
||||
starting_location:
|
||||
id: ironpeak_pass
|
||||
name: Ironpeak Pass
|
||||
region: The Frost Peaks
|
||||
description: A treacherous mountain passage near the prison you escaped, where few travelers venture
|
||||
|
||||
narrative_hooks:
|
||||
- Bounty hunters or guards searching for you
|
||||
- NPCs who recognize you from your past
|
||||
- The crime you were imprisoned for (guilty or framed)
|
||||
- Fellow prisoners or guards from Ironpeak
|
||||
- Building a new identity while hiding your past
|
||||
- Redemption arc or embracing your criminal nature
|
||||
|
||||
starting_bonus:
|
||||
trait: Hardened Survivor
|
||||
description: Prison taught you to endure hardship and seize opportunities.
|
||||
effect: +2 CON, +1 STR, bonus to survival and escape checks
|
||||
34
api/app/game_logic/__init__.py
Normal file
34
api/app/game_logic/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Game logic module for Code of Conquest.
|
||||
|
||||
This module contains core game mechanics that determine outcomes
|
||||
before they are passed to AI for narration.
|
||||
"""
|
||||
|
||||
from app.game_logic.dice import (
|
||||
CheckResult,
|
||||
SkillType,
|
||||
Difficulty,
|
||||
roll_d20,
|
||||
calculate_modifier,
|
||||
skill_check,
|
||||
get_stat_for_skill,
|
||||
perception_check,
|
||||
stealth_check,
|
||||
persuasion_check,
|
||||
lockpicking_check,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CheckResult",
|
||||
"SkillType",
|
||||
"Difficulty",
|
||||
"roll_d20",
|
||||
"calculate_modifier",
|
||||
"skill_check",
|
||||
"get_stat_for_skill",
|
||||
"perception_check",
|
||||
"stealth_check",
|
||||
"persuasion_check",
|
||||
"lockpicking_check",
|
||||
]
|
||||
247
api/app/game_logic/dice.py
Normal file
247
api/app/game_logic/dice.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Dice mechanics module for Code of Conquest.
|
||||
|
||||
This module provides core dice rolling functionality using a D20 + modifier vs DC system.
|
||||
All game chance mechanics (searches, skill checks, etc.) use these functions to determine
|
||||
outcomes before passing results to AI for narration.
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Difficulty(Enum):
|
||||
"""Standard difficulty classes for skill checks."""
|
||||
TRIVIAL = 5
|
||||
EASY = 10
|
||||
MEDIUM = 15
|
||||
HARD = 20
|
||||
VERY_HARD = 25
|
||||
NEARLY_IMPOSSIBLE = 30
|
||||
|
||||
|
||||
class SkillType(Enum):
|
||||
"""
|
||||
Skill types and their associated base stats.
|
||||
|
||||
Each skill maps to a core stat for modifier calculation.
|
||||
"""
|
||||
# Wisdom-based
|
||||
PERCEPTION = "wisdom"
|
||||
INSIGHT = "wisdom"
|
||||
SURVIVAL = "wisdom"
|
||||
MEDICINE = "wisdom"
|
||||
|
||||
# Dexterity-based
|
||||
STEALTH = "dexterity"
|
||||
ACROBATICS = "dexterity"
|
||||
SLEIGHT_OF_HAND = "dexterity"
|
||||
LOCKPICKING = "dexterity"
|
||||
|
||||
# Charisma-based
|
||||
PERSUASION = "charisma"
|
||||
DECEPTION = "charisma"
|
||||
INTIMIDATION = "charisma"
|
||||
PERFORMANCE = "charisma"
|
||||
|
||||
# Strength-based
|
||||
ATHLETICS = "strength"
|
||||
|
||||
# Intelligence-based
|
||||
ARCANA = "intelligence"
|
||||
HISTORY = "intelligence"
|
||||
INVESTIGATION = "intelligence"
|
||||
NATURE = "intelligence"
|
||||
RELIGION = "intelligence"
|
||||
|
||||
# Constitution-based
|
||||
ENDURANCE = "constitution"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
"""
|
||||
Result of a dice check.
|
||||
|
||||
Contains all information needed for UI display (dice roll animation)
|
||||
and game logic (success/failure determination).
|
||||
|
||||
Attributes:
|
||||
roll: The natural d20 roll (1-20)
|
||||
modifier: Total modifier from stats
|
||||
total: roll + modifier
|
||||
dc: Difficulty class that was checked against
|
||||
success: Whether the check succeeded
|
||||
margin: How much the check succeeded or failed by (total - dc)
|
||||
skill_type: The skill used for this check (if applicable)
|
||||
"""
|
||||
roll: int
|
||||
modifier: int
|
||||
total: int
|
||||
dc: int
|
||||
success: bool
|
||||
margin: int
|
||||
skill_type: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_critical_success(self) -> bool:
|
||||
"""Natural 20 - only relevant for combat."""
|
||||
return self.roll == 20
|
||||
|
||||
@property
|
||||
def is_critical_failure(self) -> bool:
|
||||
"""Natural 1 - only relevant for combat."""
|
||||
return self.roll == 1
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"roll": self.roll,
|
||||
"modifier": self.modifier,
|
||||
"total": self.total,
|
||||
"dc": self.dc,
|
||||
"success": self.success,
|
||||
"margin": self.margin,
|
||||
"skill_type": self.skill_type,
|
||||
}
|
||||
|
||||
|
||||
def roll_d20() -> int:
|
||||
"""
|
||||
Roll a standard 20-sided die.
|
||||
|
||||
Returns:
|
||||
Integer from 1 to 20 (inclusive)
|
||||
"""
|
||||
return random.randint(1, 20)
|
||||
|
||||
|
||||
def calculate_modifier(stat_value: int) -> int:
|
||||
"""
|
||||
Calculate the D&D-style modifier from a stat value.
|
||||
|
||||
Formula: (stat - 10) // 2
|
||||
|
||||
Examples:
|
||||
- Stat 10 = +0 modifier
|
||||
- Stat 14 = +2 modifier
|
||||
- Stat 18 = +4 modifier
|
||||
- Stat 8 = -1 modifier
|
||||
|
||||
Args:
|
||||
stat_value: The raw stat value (typically 1-20)
|
||||
|
||||
Returns:
|
||||
The modifier value (can be negative)
|
||||
"""
|
||||
return (stat_value - 10) // 2
|
||||
|
||||
|
||||
def skill_check(
|
||||
stat_value: int,
|
||||
dc: int,
|
||||
skill_type: Optional[SkillType] = None,
|
||||
bonus: int = 0
|
||||
) -> CheckResult:
|
||||
"""
|
||||
Perform a skill check: d20 + modifier vs DC.
|
||||
|
||||
Args:
|
||||
stat_value: The relevant stat value (e.g., character's wisdom for perception)
|
||||
dc: Difficulty class to beat
|
||||
skill_type: Optional skill type for logging/display
|
||||
bonus: Additional bonus (e.g., from equipment or proficiency)
|
||||
|
||||
Returns:
|
||||
CheckResult with full details of the roll
|
||||
"""
|
||||
roll = roll_d20()
|
||||
modifier = calculate_modifier(stat_value) + bonus
|
||||
total = roll + modifier
|
||||
success = total >= dc
|
||||
margin = total - dc
|
||||
|
||||
return CheckResult(
|
||||
roll=roll,
|
||||
modifier=modifier,
|
||||
total=total,
|
||||
dc=dc,
|
||||
success=success,
|
||||
margin=margin,
|
||||
skill_type=skill_type.name if skill_type else None
|
||||
)
|
||||
|
||||
|
||||
def get_stat_for_skill(skill_type: SkillType) -> str:
|
||||
"""
|
||||
Get the base stat name for a skill type.
|
||||
|
||||
Args:
|
||||
skill_type: The skill to look up
|
||||
|
||||
Returns:
|
||||
The stat name (e.g., "wisdom", "dexterity")
|
||||
"""
|
||||
return skill_type.value
|
||||
|
||||
|
||||
def perception_check(wisdom: int, dc: int, bonus: int = 0) -> CheckResult:
|
||||
"""
|
||||
Convenience function for perception checks (searching, spotting).
|
||||
|
||||
Args:
|
||||
wisdom: Character's wisdom stat
|
||||
dc: Difficulty class
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
CheckResult
|
||||
"""
|
||||
return skill_check(wisdom, dc, SkillType.PERCEPTION, bonus)
|
||||
|
||||
|
||||
def stealth_check(dexterity: int, dc: int, bonus: int = 0) -> CheckResult:
|
||||
"""
|
||||
Convenience function for stealth checks (sneaking, hiding).
|
||||
|
||||
Args:
|
||||
dexterity: Character's dexterity stat
|
||||
dc: Difficulty class
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
CheckResult
|
||||
"""
|
||||
return skill_check(dexterity, dc, SkillType.STEALTH, bonus)
|
||||
|
||||
|
||||
def persuasion_check(charisma: int, dc: int, bonus: int = 0) -> CheckResult:
|
||||
"""
|
||||
Convenience function for persuasion checks (convincing, negotiating).
|
||||
|
||||
Args:
|
||||
charisma: Character's charisma stat
|
||||
dc: Difficulty class
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
CheckResult
|
||||
"""
|
||||
return skill_check(charisma, dc, SkillType.PERSUASION, bonus)
|
||||
|
||||
|
||||
def lockpicking_check(dexterity: int, dc: int, bonus: int = 0) -> CheckResult:
|
||||
"""
|
||||
Convenience function for lockpicking checks.
|
||||
|
||||
Args:
|
||||
dexterity: Character's dexterity stat
|
||||
dc: Difficulty class
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
CheckResult
|
||||
"""
|
||||
return skill_check(dexterity, dc, SkillType.LOCKPICKING, bonus)
|
||||
87
api/app/models/__init__.py
Normal file
87
api/app/models/__init__.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Data models for Code of Conquest.
|
||||
|
||||
This package contains all dataclass models used throughout the application.
|
||||
"""
|
||||
|
||||
# Enums
|
||||
from app.models.enums import (
|
||||
EffectType,
|
||||
DamageType,
|
||||
ItemType,
|
||||
StatType,
|
||||
AbilityType,
|
||||
CombatStatus,
|
||||
SessionStatus,
|
||||
ListingStatus,
|
||||
ListingType,
|
||||
)
|
||||
|
||||
# Core models
|
||||
from app.models.stats import Stats
|
||||
from app.models.effects import Effect
|
||||
from app.models.abilities import Ability, AbilityLoader
|
||||
from app.models.items import Item
|
||||
|
||||
# Progression
|
||||
from app.models.skills import SkillNode, SkillTree, PlayerClass
|
||||
|
||||
# Character
|
||||
from app.models.character import Character
|
||||
|
||||
# Combat
|
||||
from app.models.combat import Combatant, CombatEncounter
|
||||
|
||||
# Session
|
||||
from app.models.session import (
|
||||
SessionConfig,
|
||||
GameState,
|
||||
ConversationEntry,
|
||||
GameSession,
|
||||
)
|
||||
|
||||
# Marketplace
|
||||
from app.models.marketplace import (
|
||||
Bid,
|
||||
MarketplaceListing,
|
||||
Transaction,
|
||||
ShopItem,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"EffectType",
|
||||
"DamageType",
|
||||
"ItemType",
|
||||
"StatType",
|
||||
"AbilityType",
|
||||
"CombatStatus",
|
||||
"SessionStatus",
|
||||
"ListingStatus",
|
||||
"ListingType",
|
||||
# Core models
|
||||
"Stats",
|
||||
"Effect",
|
||||
"Ability",
|
||||
"AbilityLoader",
|
||||
"Item",
|
||||
# Progression
|
||||
"SkillNode",
|
||||
"SkillTree",
|
||||
"PlayerClass",
|
||||
# Character
|
||||
"Character",
|
||||
# Combat
|
||||
"Combatant",
|
||||
"CombatEncounter",
|
||||
# Session
|
||||
"SessionConfig",
|
||||
"GameState",
|
||||
"ConversationEntry",
|
||||
"GameSession",
|
||||
# Marketplace
|
||||
"Bid",
|
||||
"MarketplaceListing",
|
||||
"Transaction",
|
||||
"ShopItem",
|
||||
]
|
||||
237
api/app/models/abilities.py
Normal file
237
api/app/models/abilities.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Ability system for combat actions and spells.
|
||||
|
||||
This module defines abilities (attacks, spells, skills) that can be used in combat.
|
||||
Abilities are loaded from YAML configuration files for data-driven design.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
import yaml
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from app.models.enums import AbilityType, DamageType, EffectType, StatType
|
||||
from app.models.effects import Effect
|
||||
from app.models.stats import Stats
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ability:
|
||||
"""
|
||||
Represents an action that can be taken in combat.
|
||||
|
||||
Abilities can deal damage, apply effects, heal, or perform other actions.
|
||||
They are loaded from YAML files for easy game design iteration.
|
||||
|
||||
Attributes:
|
||||
ability_id: Unique identifier
|
||||
name: Display name
|
||||
description: What the ability does
|
||||
ability_type: Category (attack, spell, skill, etc.)
|
||||
base_power: Base damage or healing value
|
||||
damage_type: Type of damage dealt (physical, fire, etc.)
|
||||
scaling_stat: Which stat scales this ability's power (if any)
|
||||
scaling_factor: Multiplier for scaling stat (default 0.5)
|
||||
mana_cost: MP required to use this ability
|
||||
cooldown: Turns before ability can be used again
|
||||
effects_applied: List of effects applied to target on hit
|
||||
is_aoe: Whether this affects multiple targets
|
||||
target_count: Number of targets if AoE (0 = all)
|
||||
"""
|
||||
|
||||
ability_id: str
|
||||
name: str
|
||||
description: str
|
||||
ability_type: AbilityType
|
||||
base_power: int = 0
|
||||
damage_type: Optional[DamageType] = None
|
||||
scaling_stat: Optional[StatType] = None
|
||||
scaling_factor: float = 0.5
|
||||
mana_cost: int = 0
|
||||
cooldown: int = 0
|
||||
effects_applied: List[Effect] = field(default_factory=list)
|
||||
is_aoe: bool = False
|
||||
target_count: int = 1
|
||||
|
||||
def calculate_power(self, caster_stats: Stats) -> int:
|
||||
"""
|
||||
Calculate final power based on caster's stats.
|
||||
|
||||
Formula: base_power + (scaling_stat × scaling_factor)
|
||||
Minimum power is always 1.
|
||||
|
||||
Args:
|
||||
caster_stats: The caster's effective stats
|
||||
|
||||
Returns:
|
||||
Final power value for damage or healing
|
||||
"""
|
||||
power = self.base_power
|
||||
|
||||
if self.scaling_stat:
|
||||
stat_value = getattr(caster_stats, self.scaling_stat.value)
|
||||
power += int(stat_value * self.scaling_factor)
|
||||
|
||||
return max(1, power)
|
||||
|
||||
def get_effects_to_apply(self) -> List[Effect]:
|
||||
"""
|
||||
Get a copy of effects that should be applied to target(s).
|
||||
|
||||
Creates new Effect instances to avoid sharing references.
|
||||
|
||||
Returns:
|
||||
List of Effect instances to apply
|
||||
"""
|
||||
return [
|
||||
Effect(
|
||||
effect_id=f"{self.ability_id}_{effect.name}_{id(effect)}",
|
||||
name=effect.name,
|
||||
effect_type=effect.effect_type,
|
||||
duration=effect.duration,
|
||||
power=effect.power,
|
||||
stat_affected=effect.stat_affected,
|
||||
stacks=effect.stacks,
|
||||
max_stacks=effect.max_stacks,
|
||||
source=self.ability_id,
|
||||
)
|
||||
for effect in self.effects_applied
|
||||
]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize ability to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all ability data
|
||||
"""
|
||||
data = asdict(self)
|
||||
data["ability_type"] = self.ability_type.value
|
||||
if self.damage_type:
|
||||
data["damage_type"] = self.damage_type.value
|
||||
if self.scaling_stat:
|
||||
data["scaling_stat"] = self.scaling_stat.value
|
||||
data["effects_applied"] = [effect.to_dict() for effect in self.effects_applied]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Ability':
|
||||
"""
|
||||
Deserialize ability from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing ability data
|
||||
|
||||
Returns:
|
||||
Ability instance
|
||||
"""
|
||||
# Convert string values back to enums
|
||||
ability_type = AbilityType(data["ability_type"])
|
||||
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
||||
scaling_stat = StatType(data["scaling_stat"]) if data.get("scaling_stat") else None
|
||||
|
||||
# Deserialize effects
|
||||
effects = []
|
||||
if "effects_applied" in data and data["effects_applied"]:
|
||||
effects = [Effect.from_dict(e) for e in data["effects_applied"]]
|
||||
|
||||
return cls(
|
||||
ability_id=data["ability_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
ability_type=ability_type,
|
||||
base_power=data.get("base_power", 0),
|
||||
damage_type=damage_type,
|
||||
scaling_stat=scaling_stat,
|
||||
scaling_factor=data.get("scaling_factor", 0.5),
|
||||
mana_cost=data.get("mana_cost", 0),
|
||||
cooldown=data.get("cooldown", 0),
|
||||
effects_applied=effects,
|
||||
is_aoe=data.get("is_aoe", False),
|
||||
target_count=data.get("target_count", 1),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the ability."""
|
||||
return (
|
||||
f"Ability({self.name}, {self.ability_type.value}, "
|
||||
f"power={self.base_power}, cost={self.mana_cost}MP, "
|
||||
f"cooldown={self.cooldown}t)"
|
||||
)
|
||||
|
||||
|
||||
class AbilityLoader:
|
||||
"""
|
||||
Loads abilities from YAML configuration files.
|
||||
|
||||
This allows game designers to define abilities without touching code.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the ability loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing ability YAML files
|
||||
Defaults to /app/data/abilities/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/abilities relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "abilities")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._ability_cache: Dict[str, Ability] = {}
|
||||
|
||||
def load_ability(self, ability_id: str) -> Optional[Ability]:
|
||||
"""
|
||||
Load a single ability by ID.
|
||||
|
||||
Args:
|
||||
ability_id: Unique ability identifier
|
||||
|
||||
Returns:
|
||||
Ability instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if ability_id in self._ability_cache:
|
||||
return self._ability_cache[ability_id]
|
||||
|
||||
# Load from YAML file
|
||||
yaml_file = self.data_dir / f"{ability_id}.yaml"
|
||||
if not yaml_file.exists():
|
||||
return None
|
||||
|
||||
with open(yaml_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
ability = Ability.from_dict(data)
|
||||
self._ability_cache[ability_id] = ability
|
||||
return ability
|
||||
|
||||
def load_all_abilities(self) -> Dict[str, Ability]:
|
||||
"""
|
||||
Load all abilities from the data directory.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping ability_id to Ability instance
|
||||
"""
|
||||
if not self.data_dir.exists():
|
||||
return {}
|
||||
|
||||
abilities = {}
|
||||
for yaml_file in self.data_dir.glob("*.yaml"):
|
||||
with open(yaml_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
ability = Ability.from_dict(data)
|
||||
abilities[ability.ability_id] = ability
|
||||
self._ability_cache[ability.ability_id] = ability
|
||||
|
||||
return abilities
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the ability cache, forcing reload on next access."""
|
||||
self._ability_cache.clear()
|
||||
296
api/app/models/action_prompt.py
Normal file
296
api/app/models/action_prompt.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Action Prompt Model
|
||||
|
||||
This module defines the ActionPrompt dataclass for button-based story actions.
|
||||
Each action prompt represents a predefined action that players can take during
|
||||
story progression, with tier-based availability and context filtering.
|
||||
|
||||
Usage:
|
||||
from app.models.action_prompt import ActionPrompt, ActionCategory, LocationType
|
||||
|
||||
action = ActionPrompt(
|
||||
prompt_id="ask_locals",
|
||||
category=ActionCategory.ASK_QUESTION,
|
||||
display_text="Ask locals for information",
|
||||
description="Talk to NPCs to learn about quests and rumors",
|
||||
tier_required=UserTier.FREE,
|
||||
context_filter=[LocationType.TOWN, LocationType.TAVERN],
|
||||
dm_prompt_template="The player asks locals about {{ topic }}..."
|
||||
)
|
||||
|
||||
if action.is_available(UserTier.FREE, LocationType.TOWN):
|
||||
# Show action button to player
|
||||
pass
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Any, Dict
|
||||
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckRequirement:
|
||||
"""
|
||||
Defines the dice check required for an action.
|
||||
|
||||
Used to determine outcomes before AI narration.
|
||||
"""
|
||||
check_type: str # "search" or "skill"
|
||||
skill: Optional[str] = None # For skill checks: perception, persuasion, etc.
|
||||
difficulty: str = "medium" # trivial, easy, medium, hard, very_hard
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"check_type": self.check_type,
|
||||
"skill": self.skill,
|
||||
"difficulty": self.difficulty,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CheckRequirement":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
check_type=data.get("check_type", "skill"),
|
||||
skill=data.get("skill"),
|
||||
difficulty=data.get("difficulty", "medium"),
|
||||
)
|
||||
|
||||
|
||||
class ActionCategory(str, Enum):
|
||||
"""Categories of story actions."""
|
||||
ASK_QUESTION = "ask_question" # Gather information from NPCs
|
||||
TRAVEL = "travel" # Move to a new location
|
||||
GATHER_INFO = "gather_info" # Search or investigate
|
||||
REST = "rest" # Rest and recover
|
||||
INTERACT = "interact" # Interact with objects/environment
|
||||
EXPLORE = "explore" # Explore the area
|
||||
SPECIAL = "special" # Special tier-specific actions
|
||||
|
||||
|
||||
class LocationType(str, Enum):
|
||||
"""Types of locations in the game world."""
|
||||
TOWN = "town" # Populated settlements
|
||||
TAVERN = "tavern" # Taverns and inns
|
||||
WILDERNESS = "wilderness" # Outdoor areas, forests, fields
|
||||
DUNGEON = "dungeon" # Dungeons and caves
|
||||
SAFE_AREA = "safe_area" # Protected zones, temples
|
||||
LIBRARY = "library" # Libraries and archives
|
||||
ANY = "any" # Available in all locations
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionPrompt:
|
||||
"""
|
||||
Represents a predefined story action that players can select.
|
||||
|
||||
Action prompts are displayed as buttons in the story UI. Each action
|
||||
has tier requirements and context filters to determine availability.
|
||||
|
||||
Attributes:
|
||||
prompt_id: Unique identifier for the action
|
||||
category: Category of action (ASK_QUESTION, TRAVEL, etc.)
|
||||
display_text: Text shown on the action button
|
||||
description: Tooltip/help text explaining the action
|
||||
tier_required: Minimum subscription tier required
|
||||
context_filter: List of location types where action is available
|
||||
dm_prompt_template: Jinja2 template for generating AI prompt
|
||||
icon: Optional icon name for the button
|
||||
cooldown_turns: Optional cooldown in turns before action can be used again
|
||||
"""
|
||||
|
||||
prompt_id: str
|
||||
category: ActionCategory
|
||||
display_text: str
|
||||
description: str
|
||||
tier_required: UserTier
|
||||
context_filter: List[LocationType]
|
||||
dm_prompt_template: str
|
||||
icon: Optional[str] = None
|
||||
cooldown_turns: int = 0
|
||||
requires_check: Optional[CheckRequirement] = None
|
||||
|
||||
def is_available(self, user_tier: UserTier, location_type: LocationType) -> bool:
|
||||
"""
|
||||
Check if this action is available for a user at a location.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
location_type: The current location type
|
||||
|
||||
Returns:
|
||||
True if the action is available, False otherwise
|
||||
"""
|
||||
# Check tier requirement
|
||||
if not self._tier_meets_requirement(user_tier):
|
||||
return False
|
||||
|
||||
# Check location filter
|
||||
if not self._location_matches_filter(location_type):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _tier_meets_requirement(self, user_tier: UserTier) -> bool:
|
||||
"""
|
||||
Check if user tier meets the minimum requirement.
|
||||
|
||||
Tier hierarchy: FREE < BASIC < PREMIUM < ELITE
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
True if tier requirement is met
|
||||
"""
|
||||
tier_order = {
|
||||
UserTier.FREE: 0,
|
||||
UserTier.BASIC: 1,
|
||||
UserTier.PREMIUM: 2,
|
||||
UserTier.ELITE: 3,
|
||||
}
|
||||
|
||||
user_level = tier_order.get(user_tier, 0)
|
||||
required_level = tier_order.get(self.tier_required, 0)
|
||||
|
||||
return user_level >= required_level
|
||||
|
||||
def _location_matches_filter(self, location_type: LocationType) -> bool:
|
||||
"""
|
||||
Check if location matches the context filter.
|
||||
|
||||
Args:
|
||||
location_type: The current location type
|
||||
|
||||
Returns:
|
||||
True if location matches filter
|
||||
"""
|
||||
# ANY location type matches everything
|
||||
if LocationType.ANY in self.context_filter:
|
||||
return True
|
||||
|
||||
# Check if location is in the filter list
|
||||
return location_type in self.context_filter
|
||||
|
||||
def is_locked(self, user_tier: UserTier) -> bool:
|
||||
"""
|
||||
Check if this action is locked due to tier restriction.
|
||||
|
||||
Used to show locked actions with upgrade prompts.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
True if the action is locked (tier too low)
|
||||
"""
|
||||
return not self._tier_meets_requirement(user_tier)
|
||||
|
||||
def get_lock_reason(self, user_tier: UserTier) -> Optional[str]:
|
||||
"""
|
||||
Get the reason why an action is locked.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Lock reason message, or None if not locked
|
||||
"""
|
||||
if not self._tier_meets_requirement(user_tier):
|
||||
tier_names = {
|
||||
UserTier.FREE: "Free",
|
||||
UserTier.BASIC: "Basic",
|
||||
UserTier.PREMIUM: "Premium",
|
||||
UserTier.ELITE: "Elite",
|
||||
}
|
||||
required_name = tier_names.get(self.tier_required, "Unknown")
|
||||
return f"Requires {required_name} tier or higher"
|
||||
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Convert to dictionary for JSON serialization.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the action prompt
|
||||
"""
|
||||
result = {
|
||||
"prompt_id": self.prompt_id,
|
||||
"category": self.category.value,
|
||||
"display_text": self.display_text,
|
||||
"description": self.description,
|
||||
"tier_required": self.tier_required.value,
|
||||
"context_filter": [loc.value for loc in self.context_filter],
|
||||
"dm_prompt_template": self.dm_prompt_template,
|
||||
"icon": self.icon,
|
||||
"cooldown_turns": self.cooldown_turns,
|
||||
}
|
||||
if self.requires_check:
|
||||
result["requires_check"] = self.requires_check.to_dict()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ActionPrompt":
|
||||
"""
|
||||
Create an ActionPrompt from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing action prompt data
|
||||
|
||||
Returns:
|
||||
ActionPrompt instance
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing or invalid
|
||||
"""
|
||||
# Parse category enum
|
||||
category_str = data.get("category", "")
|
||||
try:
|
||||
category = ActionCategory(category_str)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid action category: {category_str}")
|
||||
|
||||
# Parse tier enum
|
||||
tier_str = data.get("tier_required", "free")
|
||||
try:
|
||||
tier_required = UserTier(tier_str)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid user tier: {tier_str}")
|
||||
|
||||
# Parse location types
|
||||
context_filter_raw = data.get("context_filter", ["any"])
|
||||
context_filter = []
|
||||
for loc_str in context_filter_raw:
|
||||
try:
|
||||
context_filter.append(LocationType(loc_str.lower()))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid location type: {loc_str}")
|
||||
|
||||
# Parse requires_check if present
|
||||
requires_check = None
|
||||
if "requires_check" in data and data["requires_check"]:
|
||||
requires_check = CheckRequirement.from_dict(data["requires_check"])
|
||||
|
||||
return cls(
|
||||
prompt_id=data.get("prompt_id", ""),
|
||||
category=category,
|
||||
display_text=data.get("display_text", ""),
|
||||
description=data.get("description", ""),
|
||||
tier_required=tier_required,
|
||||
context_filter=context_filter,
|
||||
dm_prompt_template=data.get("dm_prompt_template", ""),
|
||||
icon=data.get("icon"),
|
||||
cooldown_turns=data.get("cooldown_turns", 0),
|
||||
requires_check=requires_check,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return (
|
||||
f"ActionPrompt(prompt_id='{self.prompt_id}', "
|
||||
f"category={self.category.value}, "
|
||||
f"tier={self.tier_required.value})"
|
||||
)
|
||||
211
api/app/models/ai_usage.py
Normal file
211
api/app/models/ai_usage.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
AI Usage data model for tracking AI generation costs and usage.
|
||||
|
||||
This module defines the AIUsageLog dataclass which represents a single AI usage
|
||||
event for tracking costs, tokens used, and generating usage analytics.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone, date
|
||||
from typing import Dict, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
"""Types of AI tasks that can be tracked."""
|
||||
STORY_PROGRESSION = "story_progression"
|
||||
COMBAT_NARRATION = "combat_narration"
|
||||
QUEST_SELECTION = "quest_selection"
|
||||
NPC_DIALOGUE = "npc_dialogue"
|
||||
GENERAL = "general"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIUsageLog:
|
||||
"""
|
||||
Represents a single AI usage event for cost and usage tracking.
|
||||
|
||||
This dataclass captures all relevant information about an AI API call
|
||||
including the user, model used, tokens consumed, and estimated cost.
|
||||
Used for:
|
||||
- Cost monitoring and budgeting
|
||||
- Usage analytics per user/tier
|
||||
- Rate limiting enforcement
|
||||
- Billing and invoicing (future)
|
||||
|
||||
Attributes:
|
||||
log_id: Unique identifier for this usage log entry
|
||||
user_id: User who made the request
|
||||
timestamp: When the request was made
|
||||
model: Model identifier (e.g., "meta/meta-llama-3-8b-instruct")
|
||||
tokens_input: Number of input tokens (prompt)
|
||||
tokens_output: Number of output tokens (response)
|
||||
tokens_total: Total tokens used (input + output)
|
||||
estimated_cost: Estimated cost in USD
|
||||
task_type: Type of task (story, combat, quest, npc)
|
||||
session_id: Optional game session ID for context
|
||||
character_id: Optional character ID for context
|
||||
request_duration_ms: How long the request took in milliseconds
|
||||
success: Whether the request completed successfully
|
||||
error_message: Error message if the request failed
|
||||
"""
|
||||
|
||||
log_id: str
|
||||
user_id: str
|
||||
timestamp: datetime
|
||||
model: str
|
||||
tokens_input: int
|
||||
tokens_output: int
|
||||
tokens_total: int
|
||||
estimated_cost: float
|
||||
task_type: TaskType
|
||||
session_id: Optional[str] = None
|
||||
character_id: Optional[str] = None
|
||||
request_duration_ms: int = 0
|
||||
success: bool = True
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert usage log to dictionary for storage.
|
||||
|
||||
Returns:
|
||||
Dictionary representation suitable for Appwrite storage
|
||||
"""
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"timestamp": self.timestamp.isoformat() if isinstance(self.timestamp, datetime) else self.timestamp,
|
||||
"model": self.model,
|
||||
"tokens_input": self.tokens_input,
|
||||
"tokens_output": self.tokens_output,
|
||||
"tokens_total": self.tokens_total,
|
||||
"estimated_cost": self.estimated_cost,
|
||||
"task_type": self.task_type.value if isinstance(self.task_type, TaskType) else self.task_type,
|
||||
"session_id": self.session_id,
|
||||
"character_id": self.character_id,
|
||||
"request_duration_ms": self.request_duration_ms,
|
||||
"success": self.success,
|
||||
"error_message": self.error_message,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "AIUsageLog":
|
||||
"""
|
||||
Create AIUsageLog from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary with usage log data
|
||||
|
||||
Returns:
|
||||
AIUsageLog instance
|
||||
"""
|
||||
# Parse timestamp
|
||||
timestamp = data.get("timestamp")
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
||||
elif timestamp is None:
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
# Parse task type
|
||||
task_type = data.get("task_type", "general")
|
||||
if isinstance(task_type, str):
|
||||
try:
|
||||
task_type = TaskType(task_type)
|
||||
except ValueError:
|
||||
task_type = TaskType.GENERAL
|
||||
|
||||
return cls(
|
||||
log_id=data.get("log_id", ""),
|
||||
user_id=data.get("user_id", ""),
|
||||
timestamp=timestamp,
|
||||
model=data.get("model", ""),
|
||||
tokens_input=data.get("tokens_input", 0),
|
||||
tokens_output=data.get("tokens_output", 0),
|
||||
tokens_total=data.get("tokens_total", 0),
|
||||
estimated_cost=data.get("estimated_cost", 0.0),
|
||||
task_type=task_type,
|
||||
session_id=data.get("session_id"),
|
||||
character_id=data.get("character_id"),
|
||||
request_duration_ms=data.get("request_duration_ms", 0),
|
||||
success=data.get("success", True),
|
||||
error_message=data.get("error_message"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyUsageSummary:
|
||||
"""
|
||||
Summary of AI usage for a specific day.
|
||||
|
||||
Used for reporting and rate limiting checks.
|
||||
|
||||
Attributes:
|
||||
date: The date of this summary
|
||||
user_id: User ID
|
||||
total_requests: Number of AI requests made
|
||||
total_tokens: Total tokens consumed
|
||||
total_input_tokens: Total input tokens
|
||||
total_output_tokens: Total output tokens
|
||||
estimated_cost: Total estimated cost in USD
|
||||
requests_by_task: Breakdown of requests by task type
|
||||
"""
|
||||
|
||||
date: date
|
||||
user_id: str
|
||||
total_requests: int
|
||||
total_tokens: int
|
||||
total_input_tokens: int
|
||||
total_output_tokens: int
|
||||
estimated_cost: float
|
||||
requests_by_task: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert summary to dictionary."""
|
||||
return {
|
||||
"date": self.date.isoformat() if isinstance(self.date, date) else self.date,
|
||||
"user_id": self.user_id,
|
||||
"total_requests": self.total_requests,
|
||||
"total_tokens": self.total_tokens,
|
||||
"total_input_tokens": self.total_input_tokens,
|
||||
"total_output_tokens": self.total_output_tokens,
|
||||
"estimated_cost": self.estimated_cost,
|
||||
"requests_by_task": self.requests_by_task,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonthlyUsageSummary:
|
||||
"""
|
||||
Summary of AI usage for a specific month.
|
||||
|
||||
Used for billing and cost projections.
|
||||
|
||||
Attributes:
|
||||
year: Year
|
||||
month: Month (1-12)
|
||||
user_id: User ID
|
||||
total_requests: Number of AI requests made
|
||||
total_tokens: Total tokens consumed
|
||||
estimated_cost: Total estimated cost in USD
|
||||
daily_breakdown: List of daily summaries
|
||||
"""
|
||||
|
||||
year: int
|
||||
month: int
|
||||
user_id: str
|
||||
total_requests: int
|
||||
total_tokens: int
|
||||
estimated_cost: float
|
||||
daily_breakdown: list = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert summary to dictionary."""
|
||||
return {
|
||||
"year": self.year,
|
||||
"month": self.month,
|
||||
"user_id": self.user_id,
|
||||
"total_requests": self.total_requests,
|
||||
"total_tokens": self.total_tokens,
|
||||
"estimated_cost": self.estimated_cost,
|
||||
"daily_breakdown": self.daily_breakdown,
|
||||
}
|
||||
452
api/app/models/character.py
Normal file
452
api/app/models/character.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Character data model - the core entity for player characters.
|
||||
|
||||
This module defines the Character dataclass which represents a player's character
|
||||
with all their stats, inventory, progression, and the critical get_effective_stats()
|
||||
method that calculates final stats from all sources.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.models.stats import Stats
|
||||
from app.models.items import Item
|
||||
from app.models.skills import PlayerClass, SkillNode
|
||||
from app.models.effects import Effect
|
||||
from app.models.enums import EffectType, StatType
|
||||
from app.models.origins import Origin
|
||||
|
||||
|
||||
@dataclass
|
||||
class Character:
|
||||
"""
|
||||
Represents a player's character.
|
||||
|
||||
This is the central data model that ties together all character-related data:
|
||||
stats, class, inventory, progression, and quests.
|
||||
|
||||
The critical method is get_effective_stats() which calculates the final stats
|
||||
by combining base stats + equipment bonuses + skill bonuses + active effects.
|
||||
|
||||
Attributes:
|
||||
character_id: Unique identifier
|
||||
user_id: Owner's user ID (from Appwrite auth)
|
||||
name: Character name
|
||||
player_class: Character's class (determines base stats and skill trees)
|
||||
origin: Character's backstory origin (saved for AI DM narrative hooks)
|
||||
level: Current level
|
||||
experience: Current XP points
|
||||
base_stats: Base stats (from class + level-ups)
|
||||
unlocked_skills: List of skill_ids that have been unlocked
|
||||
inventory: All items the character owns
|
||||
equipped: Currently equipped items by slot
|
||||
Slots: "weapon", "armor", "helmet", "boots", "accessory", etc.
|
||||
gold: Currency amount
|
||||
active_quests: List of quest IDs currently in progress
|
||||
discovered_locations: List of location IDs the character has visited
|
||||
current_location: Current location ID (tracks character position)
|
||||
"""
|
||||
|
||||
character_id: str
|
||||
user_id: str
|
||||
name: str
|
||||
player_class: PlayerClass
|
||||
origin: Origin
|
||||
level: int = 1
|
||||
experience: int = 0
|
||||
|
||||
# Stats and progression
|
||||
base_stats: Stats = field(default_factory=Stats)
|
||||
unlocked_skills: List[str] = field(default_factory=list)
|
||||
|
||||
# Inventory and equipment
|
||||
inventory: List[Item] = field(default_factory=list)
|
||||
equipped: Dict[str, Item] = field(default_factory=dict)
|
||||
gold: int = 0
|
||||
|
||||
# Quests and exploration
|
||||
active_quests: List[str] = field(default_factory=list)
|
||||
discovered_locations: List[str] = field(default_factory=list)
|
||||
current_location: Optional[str] = None # Set to origin starting location on creation
|
||||
|
||||
# NPC interaction tracking (persists across sessions)
|
||||
# Each entry: {npc_id: {interaction_count, relationship_level, dialogue_history, ...}}
|
||||
# dialogue_history: List[{player_line: str, npc_response: str}]
|
||||
npc_interactions: Dict[str, Dict] = field(default_factory=dict)
|
||||
|
||||
def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats:
|
||||
"""
|
||||
Calculate final effective stats from all sources.
|
||||
|
||||
This is the CRITICAL METHOD that combines:
|
||||
1. Base stats (from character)
|
||||
2. Equipment bonuses (from equipped items)
|
||||
3. Skill tree bonuses (from unlocked skills)
|
||||
4. Active effect modifiers (buffs/debuffs)
|
||||
|
||||
Args:
|
||||
active_effects: Currently active effects on this character (from combat)
|
||||
|
||||
Returns:
|
||||
Stats instance with all modifiers applied
|
||||
"""
|
||||
# Start with a copy of base stats
|
||||
effective = self.base_stats.copy()
|
||||
|
||||
# Apply equipment bonuses
|
||||
for item in self.equipped.values():
|
||||
for stat_name, bonus in item.stat_bonuses.items():
|
||||
if hasattr(effective, stat_name):
|
||||
current_value = getattr(effective, stat_name)
|
||||
setattr(effective, stat_name, current_value + bonus)
|
||||
|
||||
# Apply skill tree bonuses
|
||||
skill_bonuses = self._get_skill_bonuses()
|
||||
for stat_name, bonus in skill_bonuses.items():
|
||||
if hasattr(effective, stat_name):
|
||||
current_value = getattr(effective, stat_name)
|
||||
setattr(effective, stat_name, current_value + bonus)
|
||||
|
||||
# Apply active effect modifiers (buffs/debuffs)
|
||||
if active_effects:
|
||||
for effect in active_effects:
|
||||
if effect.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||
if effect.stat_affected:
|
||||
stat_name = effect.stat_affected.value
|
||||
if hasattr(effective, stat_name):
|
||||
current_value = getattr(effective, stat_name)
|
||||
modifier = effect.power * effect.stacks
|
||||
|
||||
if effect.effect_type == EffectType.BUFF:
|
||||
setattr(effective, stat_name, current_value + modifier)
|
||||
else: # DEBUFF
|
||||
# Stats can't go below 1
|
||||
setattr(effective, stat_name, max(1, current_value - modifier))
|
||||
|
||||
return effective
|
||||
|
||||
def _get_skill_bonuses(self) -> Dict[str, int]:
|
||||
"""
|
||||
Calculate total stat bonuses from unlocked skills.
|
||||
|
||||
Returns:
|
||||
Dictionary of stat bonuses from skill tree
|
||||
"""
|
||||
bonuses: Dict[str, int] = {}
|
||||
|
||||
# Get all skill nodes from all trees
|
||||
all_skills = self.player_class.get_all_skills()
|
||||
|
||||
# Sum up bonuses from unlocked skills
|
||||
for skill in all_skills:
|
||||
if skill.skill_id in self.unlocked_skills:
|
||||
skill_bonuses = skill.get_stat_bonuses()
|
||||
for stat_name, bonus in skill_bonuses.items():
|
||||
bonuses[stat_name] = bonuses.get(stat_name, 0) + bonus
|
||||
|
||||
return bonuses
|
||||
|
||||
def get_unlocked_abilities(self) -> List[str]:
|
||||
"""
|
||||
Get all ability IDs unlocked by this character's skills.
|
||||
|
||||
Returns:
|
||||
List of ability_ids from skill tree + class starting abilities
|
||||
"""
|
||||
abilities = list(self.player_class.starting_abilities)
|
||||
|
||||
# Get all skill nodes from all trees
|
||||
all_skills = self.player_class.get_all_skills()
|
||||
|
||||
# Collect abilities from unlocked skills
|
||||
for skill in all_skills:
|
||||
if skill.skill_id in self.unlocked_skills:
|
||||
abilities.extend(skill.get_unlocked_abilities())
|
||||
|
||||
return abilities
|
||||
|
||||
@property
|
||||
def class_id(self) -> str:
|
||||
"""Get class ID for template access."""
|
||||
return self.player_class.class_id
|
||||
|
||||
@property
|
||||
def origin_id(self) -> str:
|
||||
"""Get origin ID for template access."""
|
||||
return self.origin.id
|
||||
|
||||
@property
|
||||
def origin_name(self) -> str:
|
||||
"""Get origin display name for template access."""
|
||||
return self.origin.name
|
||||
|
||||
@property
|
||||
def available_skill_points(self) -> int:
|
||||
"""Calculate available skill points (1 per level minus unlocked skills)."""
|
||||
return self.level - len(self.unlocked_skills)
|
||||
|
||||
@property
|
||||
def max_hp(self) -> int:
|
||||
"""
|
||||
Calculate max HP from constitution.
|
||||
Uses the Stats.hit_points property which calculates: 10 + (constitution * 2)
|
||||
"""
|
||||
effective_stats = self.get_effective_stats()
|
||||
return effective_stats.hit_points
|
||||
|
||||
@property
|
||||
def current_hp(self) -> int:
|
||||
"""
|
||||
Get current HP.
|
||||
Outside of combat, characters are at full health.
|
||||
During combat, this would be tracked separately in the combat state.
|
||||
"""
|
||||
# For now, always return max HP (full health outside combat)
|
||||
# TODO: Track combat damage separately when implementing combat system
|
||||
return self.max_hp
|
||||
|
||||
def can_afford(self, cost: int) -> bool:
|
||||
"""Check if character has enough gold."""
|
||||
return self.gold >= cost
|
||||
|
||||
def add_gold(self, amount: int) -> None:
|
||||
"""Add gold to character's wallet."""
|
||||
self.gold += amount
|
||||
|
||||
def remove_gold(self, amount: int) -> bool:
|
||||
"""
|
||||
Remove gold from character's wallet.
|
||||
|
||||
Returns:
|
||||
True if successful, False if insufficient gold
|
||||
"""
|
||||
if not self.can_afford(amount):
|
||||
return False
|
||||
self.gold -= amount
|
||||
return True
|
||||
|
||||
def add_item(self, item: Item) -> None:
|
||||
"""Add an item to character's inventory."""
|
||||
self.inventory.append(item)
|
||||
|
||||
def remove_item(self, item_id: str) -> Optional[Item]:
|
||||
"""
|
||||
Remove an item from inventory by ID.
|
||||
|
||||
Returns:
|
||||
The removed Item or None if not found
|
||||
"""
|
||||
for i, item in enumerate(self.inventory):
|
||||
if item.item_id == item_id:
|
||||
return self.inventory.pop(i)
|
||||
return None
|
||||
|
||||
def equip_item(self, item: Item, slot: str) -> Optional[Item]:
|
||||
"""
|
||||
Equip an item to a specific slot.
|
||||
|
||||
Args:
|
||||
item: Item to equip
|
||||
slot: Equipment slot ("weapon", "armor", etc.)
|
||||
|
||||
Returns:
|
||||
Previously equipped item in that slot (or None)
|
||||
"""
|
||||
# Remove from inventory
|
||||
self.remove_item(item.item_id)
|
||||
|
||||
# Unequip current item in slot if present
|
||||
previous = self.equipped.get(slot)
|
||||
if previous:
|
||||
self.add_item(previous)
|
||||
|
||||
# Equip new item
|
||||
self.equipped[slot] = item
|
||||
return previous
|
||||
|
||||
def unequip_item(self, slot: str) -> Optional[Item]:
|
||||
"""
|
||||
Unequip an item from a slot.
|
||||
|
||||
Args:
|
||||
slot: Equipment slot to unequip from
|
||||
|
||||
Returns:
|
||||
The unequipped Item or None if slot was empty
|
||||
"""
|
||||
if slot not in self.equipped:
|
||||
return None
|
||||
|
||||
item = self.equipped.pop(slot)
|
||||
self.add_item(item)
|
||||
return item
|
||||
|
||||
def add_experience(self, xp: int) -> bool:
|
||||
"""
|
||||
Add experience points and check for level up.
|
||||
|
||||
Args:
|
||||
xp: Amount of experience to add
|
||||
|
||||
Returns:
|
||||
True if character leveled up, False otherwise
|
||||
"""
|
||||
self.experience += xp
|
||||
required_xp = self._calculate_xp_for_next_level()
|
||||
|
||||
if self.experience >= required_xp:
|
||||
self.level_up()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def level_up(self) -> None:
|
||||
"""
|
||||
Level up the character.
|
||||
|
||||
- Increases level
|
||||
- Resets experience to overflow amount
|
||||
- Could grant stat increases (future enhancement)
|
||||
"""
|
||||
required_xp = self._calculate_xp_for_next_level()
|
||||
overflow_xp = self.experience - required_xp
|
||||
|
||||
self.level += 1
|
||||
self.experience = overflow_xp
|
||||
|
||||
# Future: Apply stat increases based on class
|
||||
# For now, stats are increased manually via skill points
|
||||
|
||||
def _calculate_xp_for_next_level(self) -> int:
|
||||
"""
|
||||
Calculate XP required for next level.
|
||||
|
||||
Formula: 100 * (level ^ 1.5)
|
||||
This creates an exponential curve: 100, 282, 519, 800, 1118...
|
||||
|
||||
Returns:
|
||||
XP required for next level
|
||||
"""
|
||||
return int(100 * (self.level ** 1.5))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize character to dictionary for JSON storage.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all character data
|
||||
"""
|
||||
return {
|
||||
"character_id": self.character_id,
|
||||
"user_id": self.user_id,
|
||||
"name": self.name,
|
||||
"player_class": self.player_class.to_dict(),
|
||||
"origin": self.origin.to_dict(),
|
||||
"level": self.level,
|
||||
"experience": self.experience,
|
||||
"base_stats": self.base_stats.to_dict(),
|
||||
"unlocked_skills": self.unlocked_skills,
|
||||
"inventory": [item.to_dict() for item in self.inventory],
|
||||
"equipped": {slot: item.to_dict() for slot, item in self.equipped.items()},
|
||||
"gold": self.gold,
|
||||
"active_quests": self.active_quests,
|
||||
"discovered_locations": self.discovered_locations,
|
||||
"current_location": self.current_location,
|
||||
"npc_interactions": self.npc_interactions,
|
||||
# Computed properties for AI templates
|
||||
"current_hp": self.current_hp,
|
||||
"max_hp": self.max_hp,
|
||||
}
|
||||
|
||||
def to_story_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize only story-relevant character data for AI prompts.
|
||||
|
||||
This trimmed version reduces token usage by excluding mechanical
|
||||
details that aren't needed for narrative generation (IDs, full
|
||||
inventory details, skill trees, etc.).
|
||||
|
||||
Returns:
|
||||
Dictionary containing story-relevant character data
|
||||
"""
|
||||
effective_stats = self.get_effective_stats()
|
||||
|
||||
# Get equipped item names for context (not full details)
|
||||
equipped_summary = {}
|
||||
for slot, item in self.equipped.items():
|
||||
equipped_summary[slot] = item.name
|
||||
|
||||
# Get skill names from unlocked skills
|
||||
skill_names = []
|
||||
all_skills = self.player_class.get_all_skills()
|
||||
for skill in all_skills:
|
||||
if skill.skill_id in self.unlocked_skills:
|
||||
skill_names.append({
|
||||
"name": skill.name,
|
||||
"level": 1 # Skills don't have levels, but template expects this
|
||||
})
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"level": self.level,
|
||||
"player_class": self.player_class.name,
|
||||
"origin_name": self.origin.name,
|
||||
"current_hp": self.current_hp,
|
||||
"max_hp": self.max_hp,
|
||||
"gold": self.gold,
|
||||
# Stats for display and checks
|
||||
"stats": effective_stats.to_dict(),
|
||||
"base_stats": self.base_stats.to_dict(),
|
||||
# Simplified collections
|
||||
"skills": skill_names,
|
||||
"equipped": equipped_summary,
|
||||
"inventory_count": len(self.inventory),
|
||||
"active_quests_count": len(self.active_quests),
|
||||
# Empty list for templates that check completed_quests
|
||||
"effects": [], # Active effects passed separately in combat
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Character':
|
||||
"""
|
||||
Deserialize character from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing character data
|
||||
|
||||
Returns:
|
||||
Character instance
|
||||
"""
|
||||
from app.models.skills import PlayerClass
|
||||
|
||||
player_class = PlayerClass.from_dict(data["player_class"])
|
||||
origin = Origin.from_dict(data["origin"])
|
||||
base_stats = Stats.from_dict(data["base_stats"])
|
||||
inventory = [Item.from_dict(item) for item in data.get("inventory", [])]
|
||||
equipped = {slot: Item.from_dict(item) for slot, item in data.get("equipped", {}).items()}
|
||||
|
||||
return cls(
|
||||
character_id=data["character_id"],
|
||||
user_id=data["user_id"],
|
||||
name=data["name"],
|
||||
player_class=player_class,
|
||||
origin=origin,
|
||||
level=data.get("level", 1),
|
||||
experience=data.get("experience", 0),
|
||||
base_stats=base_stats,
|
||||
unlocked_skills=data.get("unlocked_skills", []),
|
||||
inventory=inventory,
|
||||
equipped=equipped,
|
||||
gold=data.get("gold", 0),
|
||||
active_quests=data.get("active_quests", []),
|
||||
discovered_locations=data.get("discovered_locations", []),
|
||||
current_location=data.get("current_location"),
|
||||
npc_interactions=data.get("npc_interactions", {}),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the character."""
|
||||
return (
|
||||
f"Character({self.name}, {self.player_class.name}, "
|
||||
f"Lv{self.level}, {self.gold}g)"
|
||||
)
|
||||
414
api/app/models/combat.py
Normal file
414
api/app/models/combat.py
Normal file
@@ -0,0 +1,414 @@
|
||||
"""
|
||||
Combat system data models.
|
||||
|
||||
This module defines the combat-related dataclasses including Combatant (a wrapper
|
||||
for characters/enemies in combat) and CombatEncounter (the combat state manager).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
import random
|
||||
|
||||
from app.models.stats import Stats
|
||||
from app.models.effects import Effect
|
||||
from app.models.abilities import Ability
|
||||
from app.models.enums import CombatStatus, EffectType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Combatant:
|
||||
"""
|
||||
Represents a character or enemy in combat.
|
||||
|
||||
This wraps either a player Character or an NPC/enemy for combat purposes,
|
||||
tracking combat-specific state like current HP/MP, active effects, and cooldowns.
|
||||
|
||||
Attributes:
|
||||
combatant_id: Unique identifier (character_id or enemy_id)
|
||||
name: Display name
|
||||
is_player: True if player character, False if NPC/enemy
|
||||
current_hp: Current hit points
|
||||
max_hp: Maximum hit points
|
||||
current_mp: Current mana points
|
||||
max_mp: Maximum mana points
|
||||
stats: Current combat stats (use get_effective_stats() from Character)
|
||||
active_effects: Effects currently applied to this combatant
|
||||
abilities: Available abilities for this combatant
|
||||
cooldowns: Map of ability_id to turns remaining
|
||||
initiative: Turn order value (rolled at combat start)
|
||||
"""
|
||||
|
||||
combatant_id: str
|
||||
name: str
|
||||
is_player: bool
|
||||
current_hp: int
|
||||
max_hp: int
|
||||
current_mp: int
|
||||
max_mp: int
|
||||
stats: Stats
|
||||
active_effects: List[Effect] = field(default_factory=list)
|
||||
abilities: List[str] = field(default_factory=list) # ability_ids
|
||||
cooldowns: Dict[str, int] = field(default_factory=dict)
|
||||
initiative: int = 0
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if combatant is still alive."""
|
||||
return self.current_hp > 0
|
||||
|
||||
def is_dead(self) -> bool:
|
||||
"""Check if combatant is dead."""
|
||||
return self.current_hp <= 0
|
||||
|
||||
def is_stunned(self) -> bool:
|
||||
"""Check if combatant is stunned and cannot act."""
|
||||
return any(e.effect_type == EffectType.STUN for e in self.active_effects)
|
||||
|
||||
def take_damage(self, damage: int) -> int:
|
||||
"""
|
||||
Apply damage to this combatant.
|
||||
|
||||
Damage is reduced by shields first, then HP.
|
||||
|
||||
Args:
|
||||
damage: Amount of damage to apply
|
||||
|
||||
Returns:
|
||||
Actual damage dealt to HP (after shields)
|
||||
"""
|
||||
remaining_damage = damage
|
||||
|
||||
# Apply shield absorption
|
||||
for effect in self.active_effects:
|
||||
if effect.effect_type == EffectType.SHIELD and remaining_damage > 0:
|
||||
remaining_damage = effect.reduce_shield(remaining_damage)
|
||||
|
||||
# Apply remaining damage to HP
|
||||
hp_damage = min(remaining_damage, self.current_hp)
|
||||
self.current_hp -= hp_damage
|
||||
|
||||
return hp_damage
|
||||
|
||||
def heal(self, amount: int) -> int:
|
||||
"""
|
||||
Heal this combatant.
|
||||
|
||||
Args:
|
||||
amount: Amount to heal
|
||||
|
||||
Returns:
|
||||
Actual amount healed (capped at max_hp)
|
||||
"""
|
||||
old_hp = self.current_hp
|
||||
self.current_hp = min(self.max_hp, self.current_hp + amount)
|
||||
return self.current_hp - old_hp
|
||||
|
||||
def restore_mana(self, amount: int) -> int:
|
||||
"""
|
||||
Restore mana to this combatant.
|
||||
|
||||
Args:
|
||||
amount: Amount to restore
|
||||
|
||||
Returns:
|
||||
Actual amount restored (capped at max_mp)
|
||||
"""
|
||||
old_mp = self.current_mp
|
||||
self.current_mp = min(self.max_mp, self.current_mp + amount)
|
||||
return self.current_mp - old_mp
|
||||
|
||||
def can_use_ability(self, ability_id: str, ability: Ability) -> bool:
|
||||
"""
|
||||
Check if ability can be used right now.
|
||||
|
||||
Args:
|
||||
ability_id: Ability identifier
|
||||
ability: Ability instance
|
||||
|
||||
Returns:
|
||||
True if ability can be used, False otherwise
|
||||
"""
|
||||
# Check if ability is available to this combatant
|
||||
if ability_id not in self.abilities:
|
||||
return False
|
||||
|
||||
# Check mana cost
|
||||
if self.current_mp < ability.mana_cost:
|
||||
return False
|
||||
|
||||
# Check cooldown
|
||||
if ability_id in self.cooldowns and self.cooldowns[ability_id] > 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def use_ability_cost(self, ability: Ability, ability_id: str) -> None:
|
||||
"""
|
||||
Apply the costs of using an ability (mana, cooldown).
|
||||
|
||||
Args:
|
||||
ability: Ability being used
|
||||
ability_id: Ability identifier
|
||||
"""
|
||||
# Consume mana
|
||||
self.current_mp -= ability.mana_cost
|
||||
|
||||
# Set cooldown
|
||||
if ability.cooldown > 0:
|
||||
self.cooldowns[ability_id] = ability.cooldown
|
||||
|
||||
def tick_effects(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process all active effects for this turn.
|
||||
|
||||
Returns:
|
||||
List of effect tick results
|
||||
"""
|
||||
results = []
|
||||
expired_effects = []
|
||||
|
||||
for effect in self.active_effects:
|
||||
result = effect.tick()
|
||||
|
||||
# Apply effect results
|
||||
if effect.effect_type == EffectType.DOT:
|
||||
self.take_damage(result["value"])
|
||||
elif effect.effect_type == EffectType.HOT:
|
||||
self.heal(result["value"])
|
||||
|
||||
results.append(result)
|
||||
|
||||
# Mark expired effects for removal
|
||||
if result.get("expired", False):
|
||||
expired_effects.append(effect)
|
||||
|
||||
# Remove expired effects
|
||||
for effect in expired_effects:
|
||||
self.active_effects.remove(effect)
|
||||
|
||||
return results
|
||||
|
||||
def tick_cooldowns(self) -> None:
|
||||
"""Reduce all ability cooldowns by 1 turn."""
|
||||
for ability_id in list(self.cooldowns.keys()):
|
||||
self.cooldowns[ability_id] -= 1
|
||||
if self.cooldowns[ability_id] <= 0:
|
||||
del self.cooldowns[ability_id]
|
||||
|
||||
def add_effect(self, effect: Effect) -> None:
|
||||
"""
|
||||
Add an effect to this combatant.
|
||||
|
||||
If the same effect already exists, stack it instead.
|
||||
|
||||
Args:
|
||||
effect: Effect to add
|
||||
"""
|
||||
# Check if effect already exists
|
||||
for existing in self.active_effects:
|
||||
if existing.name == effect.name and existing.effect_type == effect.effect_type:
|
||||
# Stack the effect
|
||||
existing.apply_stack(effect.duration)
|
||||
return
|
||||
|
||||
# New effect, add it
|
||||
self.active_effects.append(effect)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize combatant to dictionary."""
|
||||
return {
|
||||
"combatant_id": self.combatant_id,
|
||||
"name": self.name,
|
||||
"is_player": self.is_player,
|
||||
"current_hp": self.current_hp,
|
||||
"max_hp": self.max_hp,
|
||||
"current_mp": self.current_mp,
|
||||
"max_mp": self.max_mp,
|
||||
"stats": self.stats.to_dict(),
|
||||
"active_effects": [e.to_dict() for e in self.active_effects],
|
||||
"abilities": self.abilities,
|
||||
"cooldowns": self.cooldowns,
|
||||
"initiative": self.initiative,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Combatant':
|
||||
"""Deserialize combatant from dictionary."""
|
||||
stats = Stats.from_dict(data["stats"])
|
||||
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
|
||||
|
||||
return cls(
|
||||
combatant_id=data["combatant_id"],
|
||||
name=data["name"],
|
||||
is_player=data["is_player"],
|
||||
current_hp=data["current_hp"],
|
||||
max_hp=data["max_hp"],
|
||||
current_mp=data["current_mp"],
|
||||
max_mp=data["max_mp"],
|
||||
stats=stats,
|
||||
active_effects=active_effects,
|
||||
abilities=data.get("abilities", []),
|
||||
cooldowns=data.get("cooldowns", {}),
|
||||
initiative=data.get("initiative", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombatEncounter:
|
||||
"""
|
||||
Represents a combat encounter state.
|
||||
|
||||
Manages turn order, combatants, combat log, and victory/defeat conditions.
|
||||
|
||||
Attributes:
|
||||
encounter_id: Unique identifier
|
||||
combatants: All fighters in this combat
|
||||
turn_order: Combatant IDs sorted by initiative (highest first)
|
||||
current_turn_index: Index in turn_order for current turn
|
||||
round_number: Current round (increments each full turn cycle)
|
||||
combat_log: History of all actions taken
|
||||
status: Current combat status (active, victory, defeat, fled)
|
||||
"""
|
||||
|
||||
encounter_id: str
|
||||
combatants: List[Combatant] = field(default_factory=list)
|
||||
turn_order: List[str] = field(default_factory=list)
|
||||
current_turn_index: int = 0
|
||||
round_number: int = 1
|
||||
combat_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
status: CombatStatus = CombatStatus.ACTIVE
|
||||
|
||||
def initialize_combat(self) -> None:
|
||||
"""
|
||||
Initialize combat by rolling initiative and setting turn order.
|
||||
|
||||
Initiative: d20 + dexterity bonus
|
||||
"""
|
||||
# Roll initiative for all combatants
|
||||
for combatant in self.combatants:
|
||||
# d20 + dexterity bonus
|
||||
roll = random.randint(1, 20)
|
||||
dex_bonus = combatant.stats.dexterity // 2
|
||||
combatant.initiative = roll + dex_bonus
|
||||
|
||||
# Sort combatants by initiative (highest first)
|
||||
sorted_combatants = sorted(self.combatants, key=lambda c: c.initiative, reverse=True)
|
||||
self.turn_order = [c.combatant_id for c in sorted_combatants]
|
||||
|
||||
self.log_action("combat_start", None, f"Combat begins! Round {self.round_number}")
|
||||
|
||||
def get_current_combatant(self) -> Optional[Combatant]:
|
||||
"""Get the combatant whose turn it currently is."""
|
||||
if not self.turn_order:
|
||||
return None
|
||||
|
||||
current_id = self.turn_order[self.current_turn_index]
|
||||
return self.get_combatant(current_id)
|
||||
|
||||
def get_combatant(self, combatant_id: str) -> Optional[Combatant]:
|
||||
"""Get a combatant by ID."""
|
||||
for combatant in self.combatants:
|
||||
if combatant.combatant_id == combatant_id:
|
||||
return combatant
|
||||
return None
|
||||
|
||||
def advance_turn(self) -> None:
|
||||
"""Advance to the next combatant's turn."""
|
||||
self.current_turn_index += 1
|
||||
|
||||
# If we've cycled through all combatants, start a new round
|
||||
if self.current_turn_index >= len(self.turn_order):
|
||||
self.current_turn_index = 0
|
||||
self.round_number += 1
|
||||
self.log_action("round_start", None, f"Round {self.round_number} begins")
|
||||
|
||||
def start_turn(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process the start of a turn.
|
||||
|
||||
- Tick all effects on current combatant
|
||||
- Tick cooldowns
|
||||
- Check for stun
|
||||
|
||||
Returns:
|
||||
List of effect tick results
|
||||
"""
|
||||
combatant = self.get_current_combatant()
|
||||
if not combatant:
|
||||
return []
|
||||
|
||||
# Process effects
|
||||
effect_results = combatant.tick_effects()
|
||||
|
||||
# Reduce cooldowns
|
||||
combatant.tick_cooldowns()
|
||||
|
||||
return effect_results
|
||||
|
||||
def check_end_condition(self) -> CombatStatus:
|
||||
"""
|
||||
Check if combat should end.
|
||||
|
||||
Victory: All enemy combatants dead
|
||||
Defeat: All player combatants dead
|
||||
|
||||
Returns:
|
||||
Updated combat status
|
||||
"""
|
||||
players_alive = any(c.is_alive() and c.is_player for c in self.combatants)
|
||||
enemies_alive = any(c.is_alive() and not c.is_player for c in self.combatants)
|
||||
|
||||
if not enemies_alive and players_alive:
|
||||
self.status = CombatStatus.VICTORY
|
||||
self.log_action("combat_end", None, "Victory! All enemies defeated!")
|
||||
elif not players_alive:
|
||||
self.status = CombatStatus.DEFEAT
|
||||
self.log_action("combat_end", None, "Defeat! All players have fallen!")
|
||||
|
||||
return self.status
|
||||
|
||||
def log_action(self, action_type: str, combatant_id: Optional[str], message: str, details: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""
|
||||
Log a combat action.
|
||||
|
||||
Args:
|
||||
action_type: Type of action (attack, spell, item_use, etc.)
|
||||
combatant_id: ID of acting combatant (or None for system messages)
|
||||
message: Human-readable message
|
||||
details: Additional action details
|
||||
"""
|
||||
entry = {
|
||||
"round": self.round_number,
|
||||
"action_type": action_type,
|
||||
"combatant_id": combatant_id,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
}
|
||||
self.combat_log.append(entry)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize combat encounter to dictionary."""
|
||||
return {
|
||||
"encounter_id": self.encounter_id,
|
||||
"combatants": [c.to_dict() for c in self.combatants],
|
||||
"turn_order": self.turn_order,
|
||||
"current_turn_index": self.current_turn_index,
|
||||
"round_number": self.round_number,
|
||||
"combat_log": self.combat_log,
|
||||
"status": self.status.value,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'CombatEncounter':
|
||||
"""Deserialize combat encounter from dictionary."""
|
||||
combatants = [Combatant.from_dict(c) for c in data.get("combatants", [])]
|
||||
status = CombatStatus(data.get("status", "active"))
|
||||
|
||||
return cls(
|
||||
encounter_id=data["encounter_id"],
|
||||
combatants=combatants,
|
||||
turn_order=data.get("turn_order", []),
|
||||
current_turn_index=data.get("current_turn_index", 0),
|
||||
round_number=data.get("round_number", 1),
|
||||
combat_log=data.get("combat_log", []),
|
||||
status=status,
|
||||
)
|
||||
208
api/app/models/effects.py
Normal file
208
api/app/models/effects.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Effect system for temporary status modifiers in combat.
|
||||
|
||||
This module defines the Effect dataclass which represents temporary buffs,
|
||||
debuffs, damage over time, healing over time, stuns, and shields.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, Any, Optional
|
||||
from app.models.enums import EffectType, StatType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Effect:
|
||||
"""
|
||||
Represents a temporary effect applied to a combatant.
|
||||
|
||||
Effects are processed at the start of each turn via the tick() method.
|
||||
They can stack up to max_stacks, and duration refreshes on re-application.
|
||||
|
||||
Attributes:
|
||||
effect_id: Unique identifier for this effect instance
|
||||
name: Display name of the effect
|
||||
effect_type: Type of effect (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD)
|
||||
duration: Turns remaining before effect expires
|
||||
power: Damage/healing per turn OR stat modifier amount
|
||||
stat_affected: Which stat is modified (for BUFF/DEBUFF only)
|
||||
stacks: Number of times this effect has been stacked
|
||||
max_stacks: Maximum number of stacks allowed (default 5)
|
||||
source: Who/what applied this effect (character_id or ability_id)
|
||||
"""
|
||||
|
||||
effect_id: str
|
||||
name: str
|
||||
effect_type: EffectType
|
||||
duration: int
|
||||
power: int
|
||||
stat_affected: Optional[StatType] = None
|
||||
stacks: int = 1
|
||||
max_stacks: int = 5
|
||||
source: str = ""
|
||||
|
||||
def tick(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Process one turn of this effect.
|
||||
|
||||
Returns a dictionary describing what happened this turn, including:
|
||||
- effect_name: Name of the effect
|
||||
- effect_type: Type of effect
|
||||
- value: Damage dealt (DOT) or healing done (HOT)
|
||||
- shield_remaining: Current shield strength (SHIELD only)
|
||||
- stunned: True if this is a stun effect (STUN only)
|
||||
- stat_modifier: Amount stats are modified (BUFF/DEBUFF only)
|
||||
- expired: True if effect duration reached 0
|
||||
|
||||
Returns:
|
||||
Dictionary with effect processing results
|
||||
"""
|
||||
result = {
|
||||
"effect_name": self.name,
|
||||
"effect_type": self.effect_type.value,
|
||||
"value": 0,
|
||||
"expired": False,
|
||||
}
|
||||
|
||||
# Process effect based on type
|
||||
if self.effect_type == EffectType.DOT:
|
||||
# Damage over time: deal damage equal to power × stacks
|
||||
result["value"] = self.power * self.stacks
|
||||
result["message"] = f"{self.name} deals {result['value']} damage"
|
||||
|
||||
elif self.effect_type == EffectType.HOT:
|
||||
# Heal over time: heal equal to power × stacks
|
||||
result["value"] = self.power * self.stacks
|
||||
result["message"] = f"{self.name} heals {result['value']} HP"
|
||||
|
||||
elif self.effect_type == EffectType.STUN:
|
||||
# Stun: prevents actions this turn
|
||||
result["stunned"] = True
|
||||
result["message"] = f"{self.name} prevents actions"
|
||||
|
||||
elif self.effect_type == EffectType.SHIELD:
|
||||
# Shield: absorbs damage (power × stacks = shield strength)
|
||||
result["shield_remaining"] = self.power * self.stacks
|
||||
result["message"] = f"{self.name} absorbs up to {result['shield_remaining']} damage"
|
||||
|
||||
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||
# Buff/Debuff: modify stats
|
||||
result["stat_affected"] = self.stat_affected.value if self.stat_affected else None
|
||||
result["stat_modifier"] = self.power * self.stacks
|
||||
if self.effect_type == EffectType.BUFF:
|
||||
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
|
||||
else:
|
||||
result["message"] = f"{self.name} decreases {result['stat_affected']} by {result['stat_modifier']}"
|
||||
|
||||
# Decrease duration
|
||||
self.duration -= 1
|
||||
if self.duration <= 0:
|
||||
result["expired"] = True
|
||||
result["message"] = f"{self.name} has expired"
|
||||
|
||||
return result
|
||||
|
||||
def apply_stack(self, additional_duration: int = 0) -> None:
|
||||
"""
|
||||
Apply an additional stack of this effect.
|
||||
|
||||
Increases stack count (up to max_stacks) and refreshes duration.
|
||||
If additional_duration is provided, it's added to current duration.
|
||||
|
||||
Args:
|
||||
additional_duration: Extra turns to add (default 0 = refresh only)
|
||||
"""
|
||||
if self.stacks < self.max_stacks:
|
||||
self.stacks += 1
|
||||
|
||||
# Refresh duration or extend it
|
||||
if additional_duration > 0:
|
||||
self.duration = max(self.duration, additional_duration)
|
||||
else:
|
||||
# Find the base duration (current + turns already consumed)
|
||||
# For refresh behavior, we'd need to store original_duration
|
||||
# For now, just use the provided duration
|
||||
pass
|
||||
|
||||
def reduce_shield(self, damage: int) -> int:
|
||||
"""
|
||||
Reduce shield strength by damage amount.
|
||||
|
||||
Only applicable for SHIELD effects. Returns remaining damage after shield.
|
||||
|
||||
Args:
|
||||
damage: Amount of damage to absorb
|
||||
|
||||
Returns:
|
||||
Remaining damage after shield absorption
|
||||
"""
|
||||
if self.effect_type != EffectType.SHIELD:
|
||||
return damage
|
||||
|
||||
shield_strength = self.power * self.stacks
|
||||
if damage >= shield_strength:
|
||||
# Shield breaks completely
|
||||
remaining_damage = damage - shield_strength
|
||||
self.power = 0 # Shield depleted
|
||||
self.duration = 0 # Effect expires
|
||||
return remaining_damage
|
||||
else:
|
||||
# Shield partially absorbs damage
|
||||
damage_per_stack = damage / self.stacks
|
||||
self.power = max(0, int(self.power - damage_per_stack))
|
||||
return 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize effect to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all effect data
|
||||
"""
|
||||
data = asdict(self)
|
||||
data["effect_type"] = self.effect_type.value
|
||||
if self.stat_affected:
|
||||
data["stat_affected"] = self.stat_affected.value
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Effect':
|
||||
"""
|
||||
Deserialize effect from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing effect data
|
||||
|
||||
Returns:
|
||||
Effect instance
|
||||
"""
|
||||
# Convert string values back to enums
|
||||
effect_type = EffectType(data["effect_type"])
|
||||
stat_affected = StatType(data["stat_affected"]) if data.get("stat_affected") else None
|
||||
|
||||
return cls(
|
||||
effect_id=data["effect_id"],
|
||||
name=data["name"],
|
||||
effect_type=effect_type,
|
||||
duration=data["duration"],
|
||||
power=data["power"],
|
||||
stat_affected=stat_affected,
|
||||
stacks=data.get("stacks", 1),
|
||||
max_stacks=data.get("max_stacks", 5),
|
||||
source=data.get("source", ""),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the effect."""
|
||||
if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||
return (
|
||||
f"Effect({self.name}, {self.effect_type.value}, "
|
||||
f"{self.stat_affected.value if self.stat_affected else 'N/A'} "
|
||||
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
|
||||
f"{self.duration}t, {self.stacks}x)"
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"Effect({self.name}, {self.effect_type.value}, "
|
||||
f"power={self.power * self.stacks}, "
|
||||
f"duration={self.duration}t, stacks={self.stacks}x)"
|
||||
)
|
||||
113
api/app/models/enums.py
Normal file
113
api/app/models/enums.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Enumeration types for the Code of Conquest game system.
|
||||
|
||||
This module defines all enum types used throughout the data models to ensure
|
||||
type safety and prevent invalid values.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EffectType(Enum):
|
||||
"""Types of effects that can be applied to combatants."""
|
||||
|
||||
BUFF = "buff" # Temporarily increase stats
|
||||
DEBUFF = "debuff" # Temporarily decrease stats
|
||||
DOT = "dot" # Damage over time (poison, bleed, burn)
|
||||
HOT = "hot" # Heal over time (regeneration)
|
||||
STUN = "stun" # Prevent actions (skip turn)
|
||||
SHIELD = "shield" # Absorb damage before HP loss
|
||||
|
||||
|
||||
class DamageType(Enum):
|
||||
"""Types of damage that can be dealt in combat."""
|
||||
|
||||
PHYSICAL = "physical" # Standard weapon damage
|
||||
FIRE = "fire" # Fire-based magic damage
|
||||
ICE = "ice" # Ice-based magic damage
|
||||
LIGHTNING = "lightning" # Lightning-based magic damage
|
||||
HOLY = "holy" # Holy/divine damage
|
||||
SHADOW = "shadow" # Dark/shadow magic damage
|
||||
POISON = "poison" # Poison damage (usually DoT)
|
||||
|
||||
|
||||
class ItemType(Enum):
|
||||
"""Categories of items in the game."""
|
||||
|
||||
WEAPON = "weapon" # Adds damage, may have special effects
|
||||
ARMOR = "armor" # Adds defense/resistance
|
||||
CONSUMABLE = "consumable" # One-time use (potions, scrolls)
|
||||
QUEST_ITEM = "quest_item" # Story-related, non-tradeable
|
||||
|
||||
|
||||
class StatType(Enum):
|
||||
"""Character attribute types."""
|
||||
|
||||
STRENGTH = "strength" # Physical power
|
||||
DEXTERITY = "dexterity" # Agility and precision
|
||||
CONSTITUTION = "constitution" # Endurance and health
|
||||
INTELLIGENCE = "intelligence" # Magical power
|
||||
WISDOM = "wisdom" # Perception and insight
|
||||
CHARISMA = "charisma" # Social influence
|
||||
|
||||
|
||||
class AbilityType(Enum):
|
||||
"""Categories of abilities that can be used in combat or exploration."""
|
||||
|
||||
ATTACK = "attack" # Basic physical attack
|
||||
SPELL = "spell" # Magical spell
|
||||
SKILL = "skill" # Special class ability
|
||||
ITEM_USE = "item_use" # Using a consumable item
|
||||
DEFEND = "defend" # Defensive action
|
||||
|
||||
|
||||
class CombatStatus(Enum):
|
||||
"""Status of a combat encounter."""
|
||||
|
||||
ACTIVE = "active" # Combat is ongoing
|
||||
VICTORY = "victory" # Player(s) won
|
||||
DEFEAT = "defeat" # Player(s) lost
|
||||
FLED = "fled" # Player(s) escaped
|
||||
|
||||
|
||||
class SessionStatus(Enum):
|
||||
"""Status of a game session."""
|
||||
|
||||
ACTIVE = "active" # Session is ongoing
|
||||
COMPLETED = "completed" # Session ended normally
|
||||
TIMEOUT = "timeout" # Session ended due to inactivity
|
||||
|
||||
|
||||
class ListingStatus(Enum):
|
||||
"""Status of a marketplace listing."""
|
||||
|
||||
ACTIVE = "active" # Listing is live
|
||||
SOLD = "sold" # Item has been sold
|
||||
EXPIRED = "expired" # Listing time ran out
|
||||
REMOVED = "removed" # Seller cancelled listing
|
||||
|
||||
|
||||
class ListingType(Enum):
|
||||
"""Type of marketplace listing."""
|
||||
|
||||
AUCTION = "auction" # Bidding system
|
||||
FIXED_PRICE = "fixed_price" # Immediate purchase at set price
|
||||
|
||||
|
||||
class SessionType(Enum):
|
||||
"""Type of game session."""
|
||||
|
||||
SOLO = "solo" # Single-player session
|
||||
MULTIPLAYER = "multiplayer" # Multi-player party session
|
||||
|
||||
|
||||
class LocationType(Enum):
|
||||
"""Types of locations in the game world."""
|
||||
|
||||
TOWN = "town" # Town or city
|
||||
TAVERN = "tavern" # Tavern or inn
|
||||
WILDERNESS = "wilderness" # Outdoor wilderness areas
|
||||
DUNGEON = "dungeon" # Underground dungeons/caves
|
||||
RUINS = "ruins" # Ancient ruins
|
||||
LIBRARY = "library" # Library or archive
|
||||
SAFE_AREA = "safe_area" # Safe rest areas
|
||||
196
api/app/models/items.py
Normal file
196
api/app/models/items.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Item system for equipment, consumables, and quest items.
|
||||
|
||||
This module defines the Item dataclass representing all types of items in the game,
|
||||
including weapons, armor, consumables, and quest items.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.models.enums import ItemType, DamageType
|
||||
from app.models.effects import Effect
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
"""
|
||||
Represents an item in the game (weapon, armor, consumable, or quest item).
|
||||
|
||||
Items can provide passive stat bonuses when equipped, have weapon/armor stats,
|
||||
or provide effects when consumed.
|
||||
|
||||
Attributes:
|
||||
item_id: Unique identifier
|
||||
name: Display name
|
||||
item_type: Category (weapon, armor, consumable, quest_item)
|
||||
description: Item lore and information
|
||||
value: Gold value for buying/selling
|
||||
is_tradeable: Whether item can be sold on marketplace
|
||||
stat_bonuses: Passive bonuses to stats when equipped
|
||||
Example: {"strength": 5, "constitution": 3}
|
||||
effects_on_use: Effects applied when consumed (consumables only)
|
||||
|
||||
Weapon-specific attributes:
|
||||
damage: Base weapon damage
|
||||
damage_type: Type of damage (physical, fire, etc.)
|
||||
crit_chance: Probability of critical hit (0.0 to 1.0)
|
||||
crit_multiplier: Damage multiplier on critical hit
|
||||
|
||||
Armor-specific attributes:
|
||||
defense: Physical defense bonus
|
||||
resistance: Magical resistance bonus
|
||||
|
||||
Requirements (future):
|
||||
required_level: Minimum character level to use
|
||||
required_class: Class restriction (if any)
|
||||
"""
|
||||
|
||||
item_id: str
|
||||
name: str
|
||||
item_type: ItemType
|
||||
description: str
|
||||
value: int = 0
|
||||
is_tradeable: bool = True
|
||||
|
||||
# Passive bonuses (equipment)
|
||||
stat_bonuses: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# Active effects (consumables)
|
||||
effects_on_use: List[Effect] = field(default_factory=list)
|
||||
|
||||
# Weapon-specific
|
||||
damage: int = 0
|
||||
damage_type: Optional[DamageType] = None
|
||||
crit_chance: float = 0.05 # 5% default critical hit chance
|
||||
crit_multiplier: float = 2.0 # 2x damage on critical hit
|
||||
|
||||
# Armor-specific
|
||||
defense: int = 0
|
||||
resistance: int = 0
|
||||
|
||||
# Requirements (future expansion)
|
||||
required_level: int = 1
|
||||
required_class: Optional[str] = None
|
||||
|
||||
def is_weapon(self) -> bool:
|
||||
"""Check if this item is a weapon."""
|
||||
return self.item_type == ItemType.WEAPON
|
||||
|
||||
def is_armor(self) -> bool:
|
||||
"""Check if this item is armor."""
|
||||
return self.item_type == ItemType.ARMOR
|
||||
|
||||
def is_consumable(self) -> bool:
|
||||
"""Check if this item is a consumable."""
|
||||
return self.item_type == ItemType.CONSUMABLE
|
||||
|
||||
def is_quest_item(self) -> bool:
|
||||
"""Check if this item is a quest item."""
|
||||
return self.item_type == ItemType.QUEST_ITEM
|
||||
|
||||
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if a character can equip this item.
|
||||
|
||||
Args:
|
||||
character_level: Character's current level
|
||||
character_class: Character's class (if class restrictions exist)
|
||||
|
||||
Returns:
|
||||
True if item can be equipped, False otherwise
|
||||
"""
|
||||
# Check level requirement
|
||||
if character_level < self.required_level:
|
||||
return False
|
||||
|
||||
# Check class requirement
|
||||
if self.required_class and character_class != self.required_class:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_total_stat_bonus(self, stat_name: str) -> int:
|
||||
"""
|
||||
Get the total bonus for a specific stat from this item.
|
||||
|
||||
Args:
|
||||
stat_name: Name of the stat (e.g., "strength", "intelligence")
|
||||
|
||||
Returns:
|
||||
Bonus value for that stat (0 if not present)
|
||||
"""
|
||||
return self.stat_bonuses.get(stat_name, 0)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize item to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all item data
|
||||
"""
|
||||
data = asdict(self)
|
||||
data["item_type"] = self.item_type.value
|
||||
if self.damage_type:
|
||||
data["damage_type"] = self.damage_type.value
|
||||
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Item':
|
||||
"""
|
||||
Deserialize item from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing item data
|
||||
|
||||
Returns:
|
||||
Item instance
|
||||
"""
|
||||
# Convert string values back to enums
|
||||
item_type = ItemType(data["item_type"])
|
||||
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
||||
|
||||
# Deserialize effects
|
||||
effects = []
|
||||
if "effects_on_use" in data and data["effects_on_use"]:
|
||||
effects = [Effect.from_dict(e) for e in data["effects_on_use"]]
|
||||
|
||||
return cls(
|
||||
item_id=data["item_id"],
|
||||
name=data["name"],
|
||||
item_type=item_type,
|
||||
description=data["description"],
|
||||
value=data.get("value", 0),
|
||||
is_tradeable=data.get("is_tradeable", True),
|
||||
stat_bonuses=data.get("stat_bonuses", {}),
|
||||
effects_on_use=effects,
|
||||
damage=data.get("damage", 0),
|
||||
damage_type=damage_type,
|
||||
crit_chance=data.get("crit_chance", 0.05),
|
||||
crit_multiplier=data.get("crit_multiplier", 2.0),
|
||||
defense=data.get("defense", 0),
|
||||
resistance=data.get("resistance", 0),
|
||||
required_level=data.get("required_level", 1),
|
||||
required_class=data.get("required_class"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the item."""
|
||||
if self.is_weapon():
|
||||
return (
|
||||
f"Item({self.name}, weapon, dmg={self.damage}, "
|
||||
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
|
||||
)
|
||||
elif self.is_armor():
|
||||
return (
|
||||
f"Item({self.name}, armor, def={self.defense}, "
|
||||
f"res={self.resistance}, value={self.value}g)"
|
||||
)
|
||||
elif self.is_consumable():
|
||||
return (
|
||||
f"Item({self.name}, consumable, "
|
||||
f"effects={len(self.effects_on_use)}, value={self.value}g)"
|
||||
)
|
||||
else:
|
||||
return f"Item({self.name}, quest_item)"
|
||||
181
api/app/models/location.py
Normal file
181
api/app/models/location.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Location data models for the world exploration system.
|
||||
|
||||
This module defines Location and Region dataclasses that represent structured
|
||||
game world data. Locations are loaded from YAML files and provide rich context
|
||||
for AI narrative generation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from app.models.enums import LocationType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Location:
|
||||
"""
|
||||
Represents a defined location in the game world.
|
||||
|
||||
Locations are persistent world entities with NPCs, quests, and connections
|
||||
to other locations. They are loaded from YAML files at runtime.
|
||||
|
||||
Attributes:
|
||||
location_id: Unique identifier (e.g., "crossville_tavern")
|
||||
name: Display name (e.g., "The Rusty Anchor Tavern")
|
||||
location_type: Type of location (town, tavern, wilderness, dungeon, etc.)
|
||||
region_id: Parent region this location belongs to
|
||||
description: Full description for AI narrative context
|
||||
lore: Optional historical/background information
|
||||
ambient_description: Atmospheric details for AI narration
|
||||
available_quests: Quest IDs that can be discovered at this location
|
||||
npc_ids: List of NPC IDs present at this location
|
||||
discoverable_locations: Location IDs that can be revealed from here
|
||||
is_starting_location: Whether this is a valid origin starting point
|
||||
tags: Additional metadata tags for filtering/categorization
|
||||
"""
|
||||
|
||||
location_id: str
|
||||
name: str
|
||||
location_type: LocationType
|
||||
region_id: str
|
||||
description: str
|
||||
lore: Optional[str] = None
|
||||
ambient_description: Optional[str] = None
|
||||
available_quests: List[str] = field(default_factory=list)
|
||||
npc_ids: List[str] = field(default_factory=list)
|
||||
discoverable_locations: List[str] = field(default_factory=list)
|
||||
is_starting_location: bool = False
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize location to dictionary for JSON responses.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all location data
|
||||
"""
|
||||
return {
|
||||
"location_id": self.location_id,
|
||||
"name": self.name,
|
||||
"location_type": self.location_type.value,
|
||||
"region_id": self.region_id,
|
||||
"description": self.description,
|
||||
"lore": self.lore,
|
||||
"ambient_description": self.ambient_description,
|
||||
"available_quests": self.available_quests,
|
||||
"npc_ids": self.npc_ids,
|
||||
"discoverable_locations": self.discoverable_locations,
|
||||
"is_starting_location": self.is_starting_location,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
def to_story_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize location for AI narrative context.
|
||||
|
||||
Returns a trimmed version with only narrative-relevant data
|
||||
to reduce token usage in AI prompts.
|
||||
|
||||
Returns:
|
||||
Dictionary containing story-relevant location data
|
||||
"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"type": self.location_type.value,
|
||||
"description": self.description,
|
||||
"ambient": self.ambient_description,
|
||||
"lore": self.lore,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Location':
|
||||
"""
|
||||
Deserialize location from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing location data (from YAML or JSON)
|
||||
|
||||
Returns:
|
||||
Location instance
|
||||
"""
|
||||
# Handle location_type - can be string or LocationType enum
|
||||
location_type = data.get("location_type", "town")
|
||||
if isinstance(location_type, str):
|
||||
location_type = LocationType(location_type)
|
||||
|
||||
return cls(
|
||||
location_id=data["location_id"],
|
||||
name=data["name"],
|
||||
location_type=location_type,
|
||||
region_id=data["region_id"],
|
||||
description=data["description"],
|
||||
lore=data.get("lore"),
|
||||
ambient_description=data.get("ambient_description"),
|
||||
available_quests=data.get("available_quests", []),
|
||||
npc_ids=data.get("npc_ids", []),
|
||||
discoverable_locations=data.get("discoverable_locations", []),
|
||||
is_starting_location=data.get("is_starting_location", False),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the location."""
|
||||
return f"Location({self.location_id}, {self.name}, {self.location_type.value})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Region:
|
||||
"""
|
||||
Represents a geographical region containing multiple locations.
|
||||
|
||||
Regions group related locations together for organizational purposes
|
||||
and can contain region-wide lore or events.
|
||||
|
||||
Attributes:
|
||||
region_id: Unique identifier (e.g., "crossville")
|
||||
name: Display name (e.g., "Crossville Province")
|
||||
description: Region overview and atmosphere
|
||||
location_ids: List of all location IDs in this region
|
||||
"""
|
||||
|
||||
region_id: str
|
||||
name: str
|
||||
description: str
|
||||
location_ids: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize region to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all region data
|
||||
"""
|
||||
return {
|
||||
"region_id": self.region_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"location_ids": self.location_ids,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Region':
|
||||
"""
|
||||
Deserialize region from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing region data
|
||||
|
||||
Returns:
|
||||
Region instance
|
||||
"""
|
||||
return cls(
|
||||
region_id=data["region_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
location_ids=data.get("location_ids", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the region."""
|
||||
return f"Region({self.region_id}, {self.name}, {len(self.location_ids)} locations)"
|
||||
401
api/app/models/marketplace.py
Normal file
401
api/app/models/marketplace.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Marketplace and economy data models.
|
||||
|
||||
This module defines the marketplace-related dataclasses including
|
||||
MarketplaceListing, Bid, Transaction, and ShopItem for the player economy.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.items import Item
|
||||
from app.models.enums import ListingType, ListingStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bid:
|
||||
"""
|
||||
Represents a bid on an auction listing.
|
||||
|
||||
Attributes:
|
||||
bidder_id: User ID of the bidder
|
||||
bidder_name: Character name of the bidder
|
||||
amount: Bid amount in gold
|
||||
timestamp: ISO timestamp of when bid was placed
|
||||
"""
|
||||
|
||||
bidder_id: str
|
||||
bidder_name: str
|
||||
amount: int
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamp if not provided."""
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.utcnow().isoformat()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize bid to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Bid':
|
||||
"""Deserialize bid from dictionary."""
|
||||
return cls(
|
||||
bidder_id=data["bidder_id"],
|
||||
bidder_name=data["bidder_name"],
|
||||
amount=data["amount"],
|
||||
timestamp=data.get("timestamp", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketplaceListing:
|
||||
"""
|
||||
Represents an item listing on the player marketplace.
|
||||
|
||||
Supports both fixed-price and auction-style listings.
|
||||
|
||||
Attributes:
|
||||
listing_id: Unique identifier
|
||||
seller_id: User ID of the seller
|
||||
character_id: Character ID of the seller
|
||||
item_data: Full item details being sold
|
||||
listing_type: "auction" or "fixed_price"
|
||||
price: For fixed_price listings
|
||||
starting_bid: Minimum bid for auction listings
|
||||
current_bid: Current highest bid for auction listings
|
||||
buyout_price: Optional instant-buy price for auctions
|
||||
bids: Bid history for auction listings
|
||||
auction_end: ISO timestamp when auction ends
|
||||
status: Listing status (active, sold, expired, removed)
|
||||
created_at: ISO timestamp of listing creation
|
||||
"""
|
||||
|
||||
listing_id: str
|
||||
seller_id: str
|
||||
character_id: str
|
||||
item_data: Item
|
||||
listing_type: ListingType
|
||||
status: ListingStatus = ListingStatus.ACTIVE
|
||||
created_at: str = ""
|
||||
|
||||
# Fixed price fields
|
||||
price: int = 0
|
||||
|
||||
# Auction fields
|
||||
starting_bid: int = 0
|
||||
current_bid: int = 0
|
||||
buyout_price: int = 0
|
||||
bids: List[Bid] = field(default_factory=list)
|
||||
auction_end: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamps if not provided."""
|
||||
if not self.created_at:
|
||||
self.created_at = datetime.utcnow().isoformat()
|
||||
|
||||
def is_auction(self) -> bool:
|
||||
"""Check if this is an auction listing."""
|
||||
return self.listing_type == ListingType.AUCTION
|
||||
|
||||
def is_fixed_price(self) -> bool:
|
||||
"""Check if this is a fixed-price listing."""
|
||||
return self.listing_type == ListingType.FIXED_PRICE
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Check if listing is active."""
|
||||
return self.status == ListingStatus.ACTIVE
|
||||
|
||||
def has_ended(self) -> bool:
|
||||
"""Check if auction has ended (for auction listings)."""
|
||||
if not self.is_auction() or not self.auction_end:
|
||||
return False
|
||||
|
||||
end_time = datetime.fromisoformat(self.auction_end)
|
||||
return datetime.utcnow() >= end_time
|
||||
|
||||
def can_bid(self, bid_amount: int) -> bool:
|
||||
"""
|
||||
Check if a bid amount is valid.
|
||||
|
||||
Args:
|
||||
bid_amount: Proposed bid amount
|
||||
|
||||
Returns:
|
||||
True if bid is valid, False otherwise
|
||||
"""
|
||||
if not self.is_auction() or not self.is_active():
|
||||
return False
|
||||
|
||||
if self.has_ended():
|
||||
return False
|
||||
|
||||
# First bid must meet starting bid
|
||||
if not self.bids and bid_amount < self.starting_bid:
|
||||
return False
|
||||
|
||||
# Subsequent bids must exceed current bid
|
||||
if self.bids and bid_amount <= self.current_bid:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def place_bid(self, bidder_id: str, bidder_name: str, amount: int) -> bool:
|
||||
"""
|
||||
Place a bid on this auction.
|
||||
|
||||
Args:
|
||||
bidder_id: User ID of bidder
|
||||
bidder_name: Character name of bidder
|
||||
amount: Bid amount
|
||||
|
||||
Returns:
|
||||
True if bid was accepted, False otherwise
|
||||
"""
|
||||
if not self.can_bid(amount):
|
||||
return False
|
||||
|
||||
bid = Bid(
|
||||
bidder_id=bidder_id,
|
||||
bidder_name=bidder_name,
|
||||
amount=amount,
|
||||
)
|
||||
|
||||
self.bids.append(bid)
|
||||
self.current_bid = amount
|
||||
return True
|
||||
|
||||
def buyout(self) -> bool:
|
||||
"""
|
||||
Attempt to buy out the auction immediately.
|
||||
|
||||
Returns:
|
||||
True if buyout is available and successful, False otherwise
|
||||
"""
|
||||
if not self.is_auction() or not self.buyout_price:
|
||||
return False
|
||||
|
||||
if not self.is_active() or self.has_ended():
|
||||
return False
|
||||
|
||||
self.current_bid = self.buyout_price
|
||||
self.status = ListingStatus.SOLD
|
||||
return True
|
||||
|
||||
def get_winning_bidder(self) -> Optional[Bid]:
|
||||
"""
|
||||
Get the current winning bid.
|
||||
|
||||
Returns:
|
||||
Winning Bid or None if no bids
|
||||
"""
|
||||
if not self.bids:
|
||||
return None
|
||||
|
||||
# Bids are added chronologically, last one is highest
|
||||
return self.bids[-1]
|
||||
|
||||
def cancel_listing(self) -> bool:
|
||||
"""
|
||||
Cancel this listing (seller action).
|
||||
|
||||
Returns:
|
||||
True if successfully cancelled, False if cannot be cancelled
|
||||
"""
|
||||
if not self.is_active():
|
||||
return False
|
||||
|
||||
# Cannot cancel auction with bids
|
||||
if self.is_auction() and self.bids:
|
||||
return False
|
||||
|
||||
self.status = ListingStatus.REMOVED
|
||||
return True
|
||||
|
||||
def complete_sale(self) -> None:
|
||||
"""Mark listing as sold."""
|
||||
self.status = ListingStatus.SOLD
|
||||
|
||||
def expire_listing(self) -> None:
|
||||
"""Mark listing as expired."""
|
||||
self.status = ListingStatus.EXPIRED
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize listing to dictionary."""
|
||||
return {
|
||||
"listing_id": self.listing_id,
|
||||
"seller_id": self.seller_id,
|
||||
"character_id": self.character_id,
|
||||
"item_data": self.item_data.to_dict(),
|
||||
"listing_type": self.listing_type.value,
|
||||
"status": self.status.value,
|
||||
"created_at": self.created_at,
|
||||
"price": self.price,
|
||||
"starting_bid": self.starting_bid,
|
||||
"current_bid": self.current_bid,
|
||||
"buyout_price": self.buyout_price,
|
||||
"bids": [bid.to_dict() for bid in self.bids],
|
||||
"auction_end": self.auction_end,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'MarketplaceListing':
|
||||
"""Deserialize listing from dictionary."""
|
||||
item_data = Item.from_dict(data["item_data"])
|
||||
listing_type = ListingType(data["listing_type"])
|
||||
status = ListingStatus(data.get("status", "active"))
|
||||
bids = [Bid.from_dict(b) for b in data.get("bids", [])]
|
||||
|
||||
return cls(
|
||||
listing_id=data["listing_id"],
|
||||
seller_id=data["seller_id"],
|
||||
character_id=data["character_id"],
|
||||
item_data=item_data,
|
||||
listing_type=listing_type,
|
||||
status=status,
|
||||
created_at=data.get("created_at", ""),
|
||||
price=data.get("price", 0),
|
||||
starting_bid=data.get("starting_bid", 0),
|
||||
current_bid=data.get("current_bid", 0),
|
||||
buyout_price=data.get("buyout_price", 0),
|
||||
bids=bids,
|
||||
auction_end=data.get("auction_end", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transaction:
|
||||
"""
|
||||
Record of a completed transaction.
|
||||
|
||||
Tracks all sales for auditing and analytics.
|
||||
|
||||
Attributes:
|
||||
transaction_id: Unique identifier
|
||||
buyer_id: User ID of buyer
|
||||
seller_id: User ID of seller
|
||||
listing_id: Marketplace listing ID (if from marketplace)
|
||||
item_data: Item that was sold
|
||||
price: Final sale price in gold
|
||||
timestamp: ISO timestamp of transaction
|
||||
transaction_type: "marketplace_sale", "shop_purchase", etc.
|
||||
"""
|
||||
|
||||
transaction_id: str
|
||||
buyer_id: str
|
||||
seller_id: str
|
||||
item_data: Item
|
||||
price: int
|
||||
transaction_type: str
|
||||
listing_id: str = ""
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamp if not provided."""
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.utcnow().isoformat()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize transaction to dictionary."""
|
||||
return {
|
||||
"transaction_id": self.transaction_id,
|
||||
"buyer_id": self.buyer_id,
|
||||
"seller_id": self.seller_id,
|
||||
"listing_id": self.listing_id,
|
||||
"item_data": self.item_data.to_dict(),
|
||||
"price": self.price,
|
||||
"timestamp": self.timestamp,
|
||||
"transaction_type": self.transaction_type,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
|
||||
"""Deserialize transaction from dictionary."""
|
||||
item_data = Item.from_dict(data["item_data"])
|
||||
|
||||
return cls(
|
||||
transaction_id=data["transaction_id"],
|
||||
buyer_id=data["buyer_id"],
|
||||
seller_id=data["seller_id"],
|
||||
listing_id=data.get("listing_id", ""),
|
||||
item_data=item_data,
|
||||
price=data["price"],
|
||||
timestamp=data.get("timestamp", ""),
|
||||
transaction_type=data["transaction_type"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShopItem:
|
||||
"""
|
||||
Item sold by NPC shops.
|
||||
|
||||
Attributes:
|
||||
item_id: Item identifier
|
||||
item: Item details
|
||||
stock: Available quantity (-1 = unlimited)
|
||||
price: Fixed gold price
|
||||
"""
|
||||
|
||||
item_id: str
|
||||
item: Item
|
||||
stock: int = -1 # -1 = unlimited
|
||||
price: int = 0
|
||||
|
||||
def is_in_stock(self) -> bool:
|
||||
"""Check if item is available for purchase."""
|
||||
return self.stock != 0
|
||||
|
||||
def purchase(self, quantity: int = 1) -> bool:
|
||||
"""
|
||||
Attempt to purchase from stock.
|
||||
|
||||
Args:
|
||||
quantity: Number of items to purchase
|
||||
|
||||
Returns:
|
||||
True if purchase successful, False if insufficient stock
|
||||
"""
|
||||
if self.stock == -1: # Unlimited stock
|
||||
return True
|
||||
|
||||
if self.stock < quantity:
|
||||
return False
|
||||
|
||||
self.stock -= quantity
|
||||
return True
|
||||
|
||||
def restock(self, quantity: int) -> None:
|
||||
"""
|
||||
Add stock to this shop item.
|
||||
|
||||
Args:
|
||||
quantity: Amount to add to stock
|
||||
"""
|
||||
if self.stock == -1: # Unlimited, no need to restock
|
||||
return
|
||||
|
||||
self.stock += quantity
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize shop item to dictionary."""
|
||||
return {
|
||||
"item_id": self.item_id,
|
||||
"item": self.item.to_dict(),
|
||||
"stock": self.stock,
|
||||
"price": self.price,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ShopItem':
|
||||
"""Deserialize shop item from dictionary."""
|
||||
item = Item.from_dict(data["item"])
|
||||
|
||||
return cls(
|
||||
item_id=data["item_id"],
|
||||
item=item,
|
||||
stock=data.get("stock", -1),
|
||||
price=data.get("price", 0),
|
||||
)
|
||||
477
api/app/models/npc.py
Normal file
477
api/app/models/npc.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
NPC data models for persistent non-player characters.
|
||||
|
||||
This module defines NPC and related dataclasses that represent structured
|
||||
NPC definitions loaded from YAML files. NPCs have rich personality, knowledge,
|
||||
and interaction data that the AI uses for dialogue generation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCPersonality:
|
||||
"""
|
||||
NPC personality definition for AI dialogue generation.
|
||||
|
||||
Provides the AI with guidance on how to roleplay the NPC's character,
|
||||
including their general traits, speaking patterns, and distinctive behaviors.
|
||||
|
||||
Attributes:
|
||||
traits: List of personality descriptors (e.g., "gruff", "kind", "suspicious")
|
||||
speech_style: Description of how the NPC speaks (accent, vocabulary, patterns)
|
||||
quirks: List of distinctive behaviors or habits
|
||||
"""
|
||||
|
||||
traits: List[str]
|
||||
speech_style: str
|
||||
quirks: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize personality to dictionary."""
|
||||
return {
|
||||
"traits": self.traits,
|
||||
"speech_style": self.speech_style,
|
||||
"quirks": self.quirks,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCPersonality':
|
||||
"""Deserialize personality from dictionary."""
|
||||
return cls(
|
||||
traits=data.get("traits", []),
|
||||
speech_style=data.get("speech_style", ""),
|
||||
quirks=data.get("quirks", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCAppearance:
|
||||
"""
|
||||
NPC physical description.
|
||||
|
||||
Provides visual context for AI narration and player information.
|
||||
|
||||
Attributes:
|
||||
brief: Short one-line description for lists and quick reference
|
||||
detailed: Optional longer description for detailed encounters
|
||||
"""
|
||||
|
||||
brief: str
|
||||
detailed: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize appearance to dictionary."""
|
||||
return {
|
||||
"brief": self.brief,
|
||||
"detailed": self.detailed,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCAppearance':
|
||||
"""Deserialize appearance from dictionary."""
|
||||
if isinstance(data, str):
|
||||
# Handle simple string format
|
||||
return cls(brief=data)
|
||||
return cls(
|
||||
brief=data.get("brief", ""),
|
||||
detailed=data.get("detailed"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCKnowledgeCondition:
|
||||
"""
|
||||
Condition for NPC to reveal secret knowledge.
|
||||
|
||||
Defines when and how an NPC will share information they normally keep hidden.
|
||||
Conditions are evaluated against the character's interaction state.
|
||||
|
||||
Attributes:
|
||||
condition: Expression describing what triggers the reveal
|
||||
(e.g., "interaction_count >= 3", "relationship_level >= 75")
|
||||
reveals: The information that gets revealed when condition is met
|
||||
"""
|
||||
|
||||
condition: str
|
||||
reveals: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize condition to dictionary."""
|
||||
return {
|
||||
"condition": self.condition,
|
||||
"reveals": self.reveals,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCKnowledgeCondition':
|
||||
"""Deserialize condition from dictionary."""
|
||||
return cls(
|
||||
condition=data.get("condition", ""),
|
||||
reveals=data.get("reveals", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCKnowledge:
|
||||
"""
|
||||
Knowledge an NPC possesses - public and secret.
|
||||
|
||||
Organizes what information an NPC knows and under what circumstances
|
||||
they will share it with players.
|
||||
|
||||
Attributes:
|
||||
public: Knowledge the NPC will freely share with anyone
|
||||
secret: Knowledge the NPC keeps hidden (for AI reference only)
|
||||
will_share_if: Conditional reveals based on character interaction state
|
||||
"""
|
||||
|
||||
public: List[str] = field(default_factory=list)
|
||||
secret: List[str] = field(default_factory=list)
|
||||
will_share_if: List[NPCKnowledgeCondition] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize knowledge to dictionary."""
|
||||
return {
|
||||
"public": self.public,
|
||||
"secret": self.secret,
|
||||
"will_share_if": [c.to_dict() for c in self.will_share_if],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCKnowledge':
|
||||
"""Deserialize knowledge from dictionary."""
|
||||
conditions = [
|
||||
NPCKnowledgeCondition.from_dict(c)
|
||||
for c in data.get("will_share_if", [])
|
||||
]
|
||||
return cls(
|
||||
public=data.get("public", []),
|
||||
secret=data.get("secret", []),
|
||||
will_share_if=conditions,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCRelationship:
|
||||
"""
|
||||
NPC's relationship with another NPC.
|
||||
|
||||
Defines how this NPC feels about other NPCs in the world,
|
||||
providing context for dialogue and interactions.
|
||||
|
||||
Attributes:
|
||||
npc_id: The other NPC's identifier
|
||||
attitude: How this NPC feels (e.g., "friendly", "distrustful", "romantic")
|
||||
reason: Optional explanation for the attitude
|
||||
"""
|
||||
|
||||
npc_id: str
|
||||
attitude: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize relationship to dictionary."""
|
||||
return {
|
||||
"npc_id": self.npc_id,
|
||||
"attitude": self.attitude,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCRelationship':
|
||||
"""Deserialize relationship from dictionary."""
|
||||
return cls(
|
||||
npc_id=data["npc_id"],
|
||||
attitude=data["attitude"],
|
||||
reason=data.get("reason"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCInventoryItem:
|
||||
"""
|
||||
Item an NPC has for sale.
|
||||
|
||||
Defines items available for purchase from merchant NPCs.
|
||||
|
||||
Attributes:
|
||||
item_id: Reference to item definition
|
||||
price: Cost in gold
|
||||
quantity: Stock count (None = unlimited)
|
||||
"""
|
||||
|
||||
item_id: str
|
||||
price: int
|
||||
quantity: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize inventory item to dictionary."""
|
||||
return {
|
||||
"item_id": self.item_id,
|
||||
"price": self.price,
|
||||
"quantity": self.quantity,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCInventoryItem':
|
||||
"""Deserialize inventory item from dictionary."""
|
||||
# Handle shorthand format: { item: "ale", price: 2 }
|
||||
item_id = data.get("item_id") or data.get("item", "")
|
||||
return cls(
|
||||
item_id=item_id,
|
||||
price=data.get("price", 0),
|
||||
quantity=data.get("quantity"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCDialogueHooks:
|
||||
"""
|
||||
Pre-defined dialogue snippets for AI context.
|
||||
|
||||
Provides example phrases the AI can use or adapt to maintain
|
||||
consistent NPC voice across conversations.
|
||||
|
||||
Attributes:
|
||||
greeting: What NPC says when first addressed
|
||||
farewell: What NPC says when conversation ends
|
||||
busy: What NPC says when occupied or dismissive
|
||||
quest_complete: What NPC says when player completes their quest
|
||||
"""
|
||||
|
||||
greeting: Optional[str] = None
|
||||
farewell: Optional[str] = None
|
||||
busy: Optional[str] = None
|
||||
quest_complete: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize dialogue hooks to dictionary."""
|
||||
return {
|
||||
"greeting": self.greeting,
|
||||
"farewell": self.farewell,
|
||||
"busy": self.busy,
|
||||
"quest_complete": self.quest_complete,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCDialogueHooks':
|
||||
"""Deserialize dialogue hooks from dictionary."""
|
||||
return cls(
|
||||
greeting=data.get("greeting"),
|
||||
farewell=data.get("farewell"),
|
||||
busy=data.get("busy"),
|
||||
quest_complete=data.get("quest_complete"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPC:
|
||||
"""
|
||||
Persistent NPC definition.
|
||||
|
||||
NPCs are fixed to locations and have rich personality, knowledge,
|
||||
and interaction data used by the AI for dialogue generation.
|
||||
|
||||
Attributes:
|
||||
npc_id: Unique identifier (e.g., "npc_grom_001")
|
||||
name: Display name (e.g., "Grom Ironbeard")
|
||||
role: NPC's job/title (e.g., "bartender", "blacksmith")
|
||||
location_id: ID of location where this NPC resides
|
||||
personality: Personality traits and speech patterns
|
||||
appearance: Physical description
|
||||
knowledge: What the NPC knows (public and secret)
|
||||
relationships: How NPC feels about other NPCs
|
||||
inventory_for_sale: Items NPC sells (if merchant)
|
||||
dialogue_hooks: Pre-defined dialogue snippets
|
||||
quest_giver_for: Quest IDs this NPC can give
|
||||
reveals_locations: Location IDs this NPC can unlock through conversation
|
||||
tags: Metadata tags for filtering (e.g., "merchant", "quest_giver")
|
||||
"""
|
||||
|
||||
npc_id: str
|
||||
name: str
|
||||
role: str
|
||||
location_id: str
|
||||
personality: NPCPersonality
|
||||
appearance: NPCAppearance
|
||||
knowledge: Optional[NPCKnowledge] = None
|
||||
relationships: List[NPCRelationship] = field(default_factory=list)
|
||||
inventory_for_sale: List[NPCInventoryItem] = field(default_factory=list)
|
||||
dialogue_hooks: Optional[NPCDialogueHooks] = None
|
||||
quest_giver_for: List[str] = field(default_factory=list)
|
||||
reveals_locations: List[str] = field(default_factory=list)
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize NPC to dictionary for JSON responses.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all NPC data
|
||||
"""
|
||||
return {
|
||||
"npc_id": self.npc_id,
|
||||
"name": self.name,
|
||||
"role": self.role,
|
||||
"location_id": self.location_id,
|
||||
"personality": self.personality.to_dict(),
|
||||
"appearance": self.appearance.to_dict(),
|
||||
"knowledge": self.knowledge.to_dict() if self.knowledge else None,
|
||||
"relationships": [r.to_dict() for r in self.relationships],
|
||||
"inventory_for_sale": [i.to_dict() for i in self.inventory_for_sale],
|
||||
"dialogue_hooks": self.dialogue_hooks.to_dict() if self.dialogue_hooks else None,
|
||||
"quest_giver_for": self.quest_giver_for,
|
||||
"reveals_locations": self.reveals_locations,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
def to_story_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize NPC for AI dialogue context.
|
||||
|
||||
Returns a trimmed version focused on roleplay-relevant data
|
||||
to reduce token usage in AI prompts.
|
||||
|
||||
Returns:
|
||||
Dictionary containing story-relevant NPC data
|
||||
"""
|
||||
result = {
|
||||
"name": self.name,
|
||||
"role": self.role,
|
||||
"personality": {
|
||||
"traits": self.personality.traits,
|
||||
"speech_style": self.personality.speech_style,
|
||||
"quirks": self.personality.quirks,
|
||||
},
|
||||
"appearance": self.appearance.brief,
|
||||
}
|
||||
|
||||
# Include dialogue hooks if available
|
||||
if self.dialogue_hooks:
|
||||
result["dialogue_hooks"] = self.dialogue_hooks.to_dict()
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPC':
|
||||
"""
|
||||
Deserialize NPC from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing NPC data (from YAML or JSON)
|
||||
|
||||
Returns:
|
||||
NPC instance
|
||||
"""
|
||||
# Parse personality
|
||||
personality_data = data.get("personality", {})
|
||||
personality = NPCPersonality.from_dict(personality_data)
|
||||
|
||||
# Parse appearance
|
||||
appearance_data = data.get("appearance", {"brief": ""})
|
||||
appearance = NPCAppearance.from_dict(appearance_data)
|
||||
|
||||
# Parse knowledge (optional)
|
||||
knowledge = None
|
||||
if data.get("knowledge"):
|
||||
knowledge = NPCKnowledge.from_dict(data["knowledge"])
|
||||
|
||||
# Parse relationships
|
||||
relationships = [
|
||||
NPCRelationship.from_dict(r)
|
||||
for r in data.get("relationships", [])
|
||||
]
|
||||
|
||||
# Parse inventory
|
||||
inventory = [
|
||||
NPCInventoryItem.from_dict(i)
|
||||
for i in data.get("inventory_for_sale", [])
|
||||
]
|
||||
|
||||
# Parse dialogue hooks (optional)
|
||||
dialogue_hooks = None
|
||||
if data.get("dialogue_hooks"):
|
||||
dialogue_hooks = NPCDialogueHooks.from_dict(data["dialogue_hooks"])
|
||||
|
||||
return cls(
|
||||
npc_id=data["npc_id"],
|
||||
name=data["name"],
|
||||
role=data["role"],
|
||||
location_id=data["location_id"],
|
||||
personality=personality,
|
||||
appearance=appearance,
|
||||
knowledge=knowledge,
|
||||
relationships=relationships,
|
||||
inventory_for_sale=inventory,
|
||||
dialogue_hooks=dialogue_hooks,
|
||||
quest_giver_for=data.get("quest_giver_for", []),
|
||||
reveals_locations=data.get("reveals_locations", []),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the NPC."""
|
||||
return f"NPC({self.npc_id}, {self.name}, {self.role})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCInteractionState:
|
||||
"""
|
||||
Tracks a character's interaction history with an NPC.
|
||||
|
||||
Stored on the Character record to persist relationship data
|
||||
across multiple game sessions.
|
||||
|
||||
Attributes:
|
||||
npc_id: The NPC this state tracks
|
||||
first_met: ISO timestamp of first interaction
|
||||
last_interaction: ISO timestamp of most recent interaction
|
||||
interaction_count: Total number of conversations
|
||||
revealed_secrets: Indices of secrets that have been revealed
|
||||
relationship_level: 0-100 scale (50 is neutral)
|
||||
custom_flags: Arbitrary flags for special conditions
|
||||
(e.g., {"helped_with_rats": true})
|
||||
"""
|
||||
|
||||
npc_id: str
|
||||
first_met: str
|
||||
last_interaction: str
|
||||
interaction_count: int = 0
|
||||
revealed_secrets: List[int] = field(default_factory=list)
|
||||
relationship_level: int = 50
|
||||
custom_flags: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize interaction state to dictionary."""
|
||||
return {
|
||||
"npc_id": self.npc_id,
|
||||
"first_met": self.first_met,
|
||||
"last_interaction": self.last_interaction,
|
||||
"interaction_count": self.interaction_count,
|
||||
"revealed_secrets": self.revealed_secrets,
|
||||
"relationship_level": self.relationship_level,
|
||||
"custom_flags": self.custom_flags,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCInteractionState':
|
||||
"""Deserialize interaction state from dictionary."""
|
||||
return cls(
|
||||
npc_id=data["npc_id"],
|
||||
first_met=data["first_met"],
|
||||
last_interaction=data["last_interaction"],
|
||||
interaction_count=data.get("interaction_count", 0),
|
||||
revealed_secrets=data.get("revealed_secrets", []),
|
||||
relationship_level=data.get("relationship_level", 50),
|
||||
custom_flags=data.get("custom_flags", {}),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the interaction state."""
|
||||
return (
|
||||
f"NPCInteractionState({self.npc_id}, "
|
||||
f"interactions={self.interaction_count}, "
|
||||
f"relationship={self.relationship_level})"
|
||||
)
|
||||
148
api/app/models/origins.py
Normal file
148
api/app/models/origins.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Origin data models - character backstory and starting conditions.
|
||||
|
||||
Origins are saved to the character and referenced by the AI DM throughout
|
||||
the game to create personalized narrative experiences, quest hooks, and
|
||||
story-driven interactions.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class StartingLocation:
|
||||
"""
|
||||
Represents where a character begins their journey.
|
||||
|
||||
Attributes:
|
||||
id: Unique location identifier
|
||||
name: Display name of the location
|
||||
region: Larger geographical area this location belongs to
|
||||
description: Brief description of the location
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
description: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"region": self.region,
|
||||
"description": self.description,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'StartingLocation':
|
||||
"""Deserialize from dictionary."""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
region=data["region"],
|
||||
description=data["description"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StartingBonus:
|
||||
"""
|
||||
Represents mechanical benefits from an origin choice.
|
||||
|
||||
Attributes:
|
||||
trait: Name of the trait/ability granted
|
||||
description: What the trait represents narratively
|
||||
effect: Mechanical game effect (stat bonuses, special abilities, etc.)
|
||||
"""
|
||||
trait: str
|
||||
description: str
|
||||
effect: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"trait": self.trait,
|
||||
"description": self.description,
|
||||
"effect": self.effect,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'StartingBonus':
|
||||
"""Deserialize from dictionary."""
|
||||
return cls(
|
||||
trait=data["trait"],
|
||||
description=data["description"],
|
||||
effect=data["effect"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Origin:
|
||||
"""
|
||||
Represents a character's backstory and starting conditions.
|
||||
|
||||
Origins are permanent character attributes that the AI DM uses to
|
||||
create personalized narratives, generate relevant quest hooks, and
|
||||
tailor NPC interactions throughout the game.
|
||||
|
||||
Attributes:
|
||||
id: Unique origin identifier (e.g., "soul_revenant")
|
||||
name: Display name (e.g., "Soul Revenant")
|
||||
description: Full backstory text that explains the origin
|
||||
starting_location: Where the character begins their journey
|
||||
narrative_hooks: List of story elements the AI can reference
|
||||
starting_bonus: Mechanical benefits from this origin
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
starting_location: StartingLocation
|
||||
narrative_hooks: List[str] = field(default_factory=list)
|
||||
starting_bonus: StartingBonus = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize origin to dictionary for JSON storage.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all origin data
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"starting_location": self.starting_location.to_dict(),
|
||||
"narrative_hooks": self.narrative_hooks,
|
||||
"starting_bonus": self.starting_bonus.to_dict() if self.starting_bonus else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Origin':
|
||||
"""
|
||||
Deserialize origin from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing origin data
|
||||
|
||||
Returns:
|
||||
Origin instance
|
||||
"""
|
||||
starting_location = StartingLocation.from_dict(data["starting_location"])
|
||||
starting_bonus = None
|
||||
if data.get("starting_bonus"):
|
||||
starting_bonus = StartingBonus.from_dict(data["starting_bonus"])
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
starting_location=starting_location,
|
||||
narrative_hooks=data.get("narrative_hooks", []),
|
||||
starting_bonus=starting_bonus,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the origin."""
|
||||
return f"Origin({self.name}, starts at {self.starting_location.name})"
|
||||
411
api/app/models/session.py
Normal file
411
api/app/models/session.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Game session data models.
|
||||
|
||||
This module defines the session-related dataclasses including SessionConfig,
|
||||
GameState, and GameSession which manage multiplayer party sessions.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models.combat import CombatEncounter
|
||||
from app.models.enums import SessionStatus, SessionType
|
||||
from app.models.action_prompt import LocationType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionConfig:
|
||||
"""
|
||||
Configuration settings for a game session.
|
||||
|
||||
Attributes:
|
||||
min_players: Minimum players required (session ends if below this)
|
||||
timeout_minutes: Inactivity timeout in minutes
|
||||
auto_save_interval: Turns between automatic saves
|
||||
"""
|
||||
|
||||
min_players: int = 1
|
||||
timeout_minutes: int = 30
|
||||
auto_save_interval: int = 5
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize configuration to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SessionConfig':
|
||||
"""Deserialize configuration from dictionary."""
|
||||
return cls(
|
||||
min_players=data.get("min_players", 1),
|
||||
timeout_minutes=data.get("timeout_minutes", 30),
|
||||
auto_save_interval=data.get("auto_save_interval", 5),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
"""
|
||||
Current world/quest state for a game session.
|
||||
|
||||
Attributes:
|
||||
current_location: Current location name/ID
|
||||
location_type: Type of current location (town, tavern, wilderness, etc.)
|
||||
discovered_locations: All location IDs the party has visited
|
||||
active_quests: Quest IDs currently in progress
|
||||
world_events: Server-wide events affecting this session
|
||||
"""
|
||||
|
||||
current_location: str = "crossville_village"
|
||||
location_type: LocationType = LocationType.TOWN
|
||||
discovered_locations: List[str] = field(default_factory=list)
|
||||
active_quests: List[str] = field(default_factory=list)
|
||||
world_events: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize game state to dictionary."""
|
||||
return {
|
||||
"current_location": self.current_location,
|
||||
"location_type": self.location_type.value,
|
||||
"discovered_locations": self.discovered_locations,
|
||||
"active_quests": self.active_quests,
|
||||
"world_events": self.world_events,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'GameState':
|
||||
"""Deserialize game state from dictionary."""
|
||||
# Handle location_type as either string or enum
|
||||
location_type_value = data.get("location_type", "town")
|
||||
if isinstance(location_type_value, str):
|
||||
location_type = LocationType(location_type_value)
|
||||
else:
|
||||
location_type = location_type_value
|
||||
|
||||
return cls(
|
||||
current_location=data.get("current_location", "crossville_village"),
|
||||
location_type=location_type,
|
||||
discovered_locations=data.get("discovered_locations", []),
|
||||
active_quests=data.get("active_quests", []),
|
||||
world_events=data.get("world_events", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationEntry:
|
||||
"""
|
||||
Single entry in the conversation history.
|
||||
|
||||
Attributes:
|
||||
turn: Turn number
|
||||
character_id: Acting character's ID
|
||||
character_name: Acting character's name
|
||||
action: Player's action/input text
|
||||
dm_response: AI Dungeon Master's response
|
||||
timestamp: ISO timestamp of when entry was created
|
||||
combat_log: Combat actions if any occurred this turn
|
||||
quest_offered: Quest offering info if a quest was offered this turn
|
||||
"""
|
||||
|
||||
turn: int
|
||||
character_id: str
|
||||
character_name: str
|
||||
action: str
|
||||
dm_response: str
|
||||
timestamp: str = ""
|
||||
combat_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
quest_offered: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamp if not provided."""
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize conversation entry to dictionary."""
|
||||
result = {
|
||||
"turn": self.turn,
|
||||
"character_id": self.character_id,
|
||||
"character_name": self.character_name,
|
||||
"action": self.action,
|
||||
"dm_response": self.dm_response,
|
||||
"timestamp": self.timestamp,
|
||||
"combat_log": self.combat_log,
|
||||
}
|
||||
if self.quest_offered:
|
||||
result["quest_offered"] = self.quest_offered
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ConversationEntry':
|
||||
"""Deserialize conversation entry from dictionary."""
|
||||
return cls(
|
||||
turn=data["turn"],
|
||||
character_id=data.get("character_id", ""),
|
||||
character_name=data.get("character_name", ""),
|
||||
action=data["action"],
|
||||
dm_response=data["dm_response"],
|
||||
timestamp=data.get("timestamp", ""),
|
||||
combat_log=data.get("combat_log", []),
|
||||
quest_offered=data.get("quest_offered"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameSession:
|
||||
"""
|
||||
Represents a game session (solo or multiplayer).
|
||||
|
||||
A session can have one or more players (party) and tracks the entire
|
||||
game state including conversation history, combat encounters, and
|
||||
turn order.
|
||||
|
||||
Attributes:
|
||||
session_id: Unique identifier
|
||||
session_type: Type of session (solo or multiplayer)
|
||||
solo_character_id: Character ID for single-player sessions (None for multiplayer)
|
||||
user_id: Owner of the session
|
||||
party_member_ids: Character IDs in this party (multiplayer only)
|
||||
config: Session configuration settings
|
||||
combat_encounter: Current combat (None if not in combat)
|
||||
conversation_history: Turn-by-turn log of actions and DM responses
|
||||
game_state: Current world/quest state
|
||||
turn_order: Character turn order
|
||||
current_turn: Index in turn_order for current turn
|
||||
turn_number: Global turn counter
|
||||
created_at: ISO timestamp of session creation
|
||||
last_activity: ISO timestamp of last action
|
||||
status: Current session status (active, completed, timeout)
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
session_type: SessionType = SessionType.SOLO
|
||||
solo_character_id: Optional[str] = None
|
||||
user_id: str = ""
|
||||
party_member_ids: List[str] = field(default_factory=list)
|
||||
config: SessionConfig = field(default_factory=SessionConfig)
|
||||
combat_encounter: Optional[CombatEncounter] = None
|
||||
conversation_history: List[ConversationEntry] = field(default_factory=list)
|
||||
game_state: GameState = field(default_factory=GameState)
|
||||
turn_order: List[str] = field(default_factory=list)
|
||||
current_turn: int = 0
|
||||
turn_number: int = 0
|
||||
created_at: str = ""
|
||||
last_activity: str = ""
|
||||
status: SessionStatus = SessionStatus.ACTIVE
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamps if not provided."""
|
||||
if not self.created_at:
|
||||
self.created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if not self.last_activity:
|
||||
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def is_in_combat(self) -> bool:
|
||||
"""Check if session is currently in combat."""
|
||||
return self.combat_encounter is not None
|
||||
|
||||
def start_combat(self, encounter: CombatEncounter) -> None:
|
||||
"""
|
||||
Start a combat encounter.
|
||||
|
||||
Args:
|
||||
encounter: The combat encounter to begin
|
||||
"""
|
||||
self.combat_encounter = encounter
|
||||
self.update_activity()
|
||||
|
||||
def end_combat(self) -> None:
|
||||
"""End the current combat encounter."""
|
||||
self.combat_encounter = None
|
||||
self.update_activity()
|
||||
|
||||
def advance_turn(self) -> str:
|
||||
"""
|
||||
Advance to the next player's turn.
|
||||
|
||||
Returns:
|
||||
Character ID whose turn it now is
|
||||
"""
|
||||
if not self.turn_order:
|
||||
return ""
|
||||
|
||||
self.current_turn = (self.current_turn + 1) % len(self.turn_order)
|
||||
self.turn_number += 1
|
||||
self.update_activity()
|
||||
|
||||
return self.turn_order[self.current_turn]
|
||||
|
||||
def get_current_character_id(self) -> Optional[str]:
|
||||
"""Get the character ID whose turn it currently is."""
|
||||
if not self.turn_order:
|
||||
return None
|
||||
return self.turn_order[self.current_turn]
|
||||
|
||||
def add_conversation_entry(self, entry: ConversationEntry) -> None:
|
||||
"""
|
||||
Add an entry to the conversation history.
|
||||
|
||||
Args:
|
||||
entry: Conversation entry to add
|
||||
"""
|
||||
self.conversation_history.append(entry)
|
||||
self.update_activity()
|
||||
|
||||
def update_activity(self) -> None:
|
||||
"""Update the last activity timestamp to now."""
|
||||
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def add_party_member(self, character_id: str) -> None:
|
||||
"""
|
||||
Add a character to the party.
|
||||
|
||||
Args:
|
||||
character_id: Character ID to add
|
||||
"""
|
||||
if character_id not in self.party_member_ids:
|
||||
self.party_member_ids.append(character_id)
|
||||
self.update_activity()
|
||||
|
||||
def remove_party_member(self, character_id: str) -> None:
|
||||
"""
|
||||
Remove a character from the party.
|
||||
|
||||
Args:
|
||||
character_id: Character ID to remove
|
||||
"""
|
||||
if character_id in self.party_member_ids:
|
||||
self.party_member_ids.remove(character_id)
|
||||
# Also remove from turn order
|
||||
if character_id in self.turn_order:
|
||||
self.turn_order.remove(character_id)
|
||||
self.update_activity()
|
||||
|
||||
def check_timeout(self) -> bool:
|
||||
"""
|
||||
Check if session has timed out due to inactivity.
|
||||
|
||||
Returns:
|
||||
True if session should be marked as timed out
|
||||
"""
|
||||
if self.status != SessionStatus.ACTIVE:
|
||||
return False
|
||||
|
||||
# Calculate time since last activity
|
||||
last_activity_str = self.last_activity.replace("Z", "+00:00")
|
||||
last_activity_time = datetime.fromisoformat(last_activity_str)
|
||||
now = datetime.now(timezone.utc)
|
||||
elapsed_minutes = (now - last_activity_time).total_seconds() / 60
|
||||
|
||||
if elapsed_minutes >= self.config.timeout_minutes:
|
||||
self.status = SessionStatus.TIMEOUT
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def check_min_players(self) -> bool:
|
||||
"""
|
||||
Check if session still has minimum required players.
|
||||
|
||||
Returns:
|
||||
True if session should continue, False if it should end
|
||||
"""
|
||||
if len(self.party_member_ids) < self.config.min_players:
|
||||
if self.status == SessionStatus.ACTIVE:
|
||||
self.status = SessionStatus.COMPLETED
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_solo(self) -> bool:
|
||||
"""Check if this is a solo session."""
|
||||
return self.session_type == SessionType.SOLO
|
||||
|
||||
def get_character_id(self) -> Optional[str]:
|
||||
"""
|
||||
Get the primary character ID for the session.
|
||||
|
||||
For solo sessions, returns solo_character_id.
|
||||
For multiplayer, returns the current character in turn order.
|
||||
"""
|
||||
if self.is_solo():
|
||||
return self.solo_character_id
|
||||
return self.get_current_character_id()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize game session to dictionary."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"session_type": self.session_type.value,
|
||||
"solo_character_id": self.solo_character_id,
|
||||
"user_id": self.user_id,
|
||||
"party_member_ids": self.party_member_ids,
|
||||
"config": self.config.to_dict(),
|
||||
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None,
|
||||
"conversation_history": [entry.to_dict() for entry in self.conversation_history],
|
||||
"game_state": self.game_state.to_dict(),
|
||||
"turn_order": self.turn_order,
|
||||
"current_turn": self.current_turn,
|
||||
"turn_number": self.turn_number,
|
||||
"created_at": self.created_at,
|
||||
"last_activity": self.last_activity,
|
||||
"status": self.status.value,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'GameSession':
|
||||
"""Deserialize game session from dictionary."""
|
||||
config = SessionConfig.from_dict(data.get("config", {}))
|
||||
game_state = GameState.from_dict(data.get("game_state", {}))
|
||||
conversation_history = [
|
||||
ConversationEntry.from_dict(entry)
|
||||
for entry in data.get("conversation_history", [])
|
||||
]
|
||||
|
||||
combat_encounter = None
|
||||
if data.get("combat_encounter"):
|
||||
combat_encounter = CombatEncounter.from_dict(data["combat_encounter"])
|
||||
|
||||
status = SessionStatus(data.get("status", "active"))
|
||||
|
||||
# Handle session_type as either string or enum
|
||||
session_type_value = data.get("session_type", "solo")
|
||||
if isinstance(session_type_value, str):
|
||||
session_type = SessionType(session_type_value)
|
||||
else:
|
||||
session_type = session_type_value
|
||||
|
||||
return cls(
|
||||
session_id=data["session_id"],
|
||||
session_type=session_type,
|
||||
solo_character_id=data.get("solo_character_id"),
|
||||
user_id=data.get("user_id", ""),
|
||||
party_member_ids=data.get("party_member_ids", []),
|
||||
config=config,
|
||||
combat_encounter=combat_encounter,
|
||||
conversation_history=conversation_history,
|
||||
game_state=game_state,
|
||||
turn_order=data.get("turn_order", []),
|
||||
current_turn=data.get("current_turn", 0),
|
||||
turn_number=data.get("turn_number", 0),
|
||||
created_at=data.get("created_at", ""),
|
||||
last_activity=data.get("last_activity", ""),
|
||||
status=status,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the session."""
|
||||
if self.is_solo():
|
||||
return (
|
||||
f"GameSession({self.session_id}, "
|
||||
f"type=solo, "
|
||||
f"char={self.solo_character_id}, "
|
||||
f"turn={self.turn_number}, "
|
||||
f"status={self.status.value})"
|
||||
)
|
||||
return (
|
||||
f"GameSession({self.session_id}, "
|
||||
f"type=multiplayer, "
|
||||
f"party={len(self.party_member_ids)}, "
|
||||
f"turn={self.turn_number}, "
|
||||
f"status={self.status.value})"
|
||||
)
|
||||
290
api/app/models/skills.py
Normal file
290
api/app/models/skills.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Skill tree and character class system.
|
||||
|
||||
This module defines the progression system including skill nodes, skill trees,
|
||||
and player classes. Characters unlock skills by spending skill points earned
|
||||
through leveling.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.models.stats import Stats
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillNode:
|
||||
"""
|
||||
Represents a single skill in a skill tree.
|
||||
|
||||
Skills can provide passive bonuses, unlock active abilities, or grant
|
||||
access to new features (like equipment types).
|
||||
|
||||
Attributes:
|
||||
skill_id: Unique identifier
|
||||
name: Display name
|
||||
description: What this skill does
|
||||
tier: Skill tier (1-5, where 1 is basic and 5 is master)
|
||||
prerequisites: List of skill_ids that must be unlocked first
|
||||
effects: Dictionary of effects this skill provides
|
||||
Examples:
|
||||
- Passive bonuses: {"strength": 5, "defense": 10}
|
||||
- Ability unlocks: {"unlocks_ability": "shield_bash"}
|
||||
- Feature access: {"unlocks_equipment": "heavy_armor"}
|
||||
unlocked: Current unlock status (used during gameplay)
|
||||
"""
|
||||
|
||||
skill_id: str
|
||||
name: str
|
||||
description: str
|
||||
tier: int # 1-5
|
||||
prerequisites: List[str] = field(default_factory=list)
|
||||
effects: Dict[str, Any] = field(default_factory=dict)
|
||||
unlocked: bool = False
|
||||
|
||||
def has_prerequisites_met(self, unlocked_skills: List[str]) -> bool:
|
||||
"""
|
||||
Check if all prerequisites for this skill are met.
|
||||
|
||||
Args:
|
||||
unlocked_skills: List of skill_ids the character has unlocked
|
||||
|
||||
Returns:
|
||||
True if all prerequisites are met, False otherwise
|
||||
"""
|
||||
return all(prereq in unlocked_skills for prereq in self.prerequisites)
|
||||
|
||||
def get_stat_bonuses(self) -> Dict[str, int]:
|
||||
"""
|
||||
Extract stat bonuses from this skill's effects.
|
||||
|
||||
Returns:
|
||||
Dictionary of stat bonuses (e.g., {"strength": 5, "defense": 3})
|
||||
"""
|
||||
bonuses = {}
|
||||
for key, value in self.effects.items():
|
||||
# Look for stat names in effects
|
||||
if key in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]:
|
||||
bonuses[key] = value
|
||||
elif key == "defense" or key == "resistance" or key == "hit_points" or key == "mana_points":
|
||||
bonuses[key] = value
|
||||
return bonuses
|
||||
|
||||
def get_unlocked_abilities(self) -> List[str]:
|
||||
"""
|
||||
Extract ability IDs unlocked by this skill.
|
||||
|
||||
Returns:
|
||||
List of ability_ids this skill unlocks
|
||||
"""
|
||||
abilities = []
|
||||
if "unlocks_ability" in self.effects:
|
||||
ability = self.effects["unlocks_ability"]
|
||||
if isinstance(ability, list):
|
||||
abilities.extend(ability)
|
||||
else:
|
||||
abilities.append(ability)
|
||||
return abilities
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize skill node to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SkillNode':
|
||||
"""Deserialize skill node from dictionary."""
|
||||
return cls(
|
||||
skill_id=data["skill_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
tier=data["tier"],
|
||||
prerequisites=data.get("prerequisites", []),
|
||||
effects=data.get("effects", {}),
|
||||
unlocked=data.get("unlocked", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillTree:
|
||||
"""
|
||||
Represents a complete skill tree for a character class.
|
||||
|
||||
Each class has 2+ skill trees representing different specializations.
|
||||
|
||||
Attributes:
|
||||
tree_id: Unique identifier
|
||||
name: Display name (e.g., "Shield Bearer", "Pyromancy")
|
||||
description: Theme and purpose of this tree
|
||||
nodes: All skill nodes in this tree (organized by tier)
|
||||
"""
|
||||
|
||||
tree_id: str
|
||||
name: str
|
||||
description: str
|
||||
nodes: List[SkillNode] = field(default_factory=list)
|
||||
|
||||
def can_unlock(self, skill_id: str, unlocked_skills: List[str]) -> bool:
|
||||
"""
|
||||
Check if a specific skill can be unlocked.
|
||||
|
||||
Validates:
|
||||
1. Skill exists in this tree
|
||||
2. Prerequisites are met
|
||||
3. Tier progression rules (must unlock tier N before tier N+1)
|
||||
|
||||
Args:
|
||||
skill_id: The skill to check
|
||||
unlocked_skills: Currently unlocked skill_ids
|
||||
|
||||
Returns:
|
||||
True if skill can be unlocked, False otherwise
|
||||
"""
|
||||
# Find the skill node
|
||||
skill_node = None
|
||||
for node in self.nodes:
|
||||
if node.skill_id == skill_id:
|
||||
skill_node = node
|
||||
break
|
||||
|
||||
if not skill_node:
|
||||
return False # Skill not in this tree
|
||||
|
||||
# Check if already unlocked
|
||||
if skill_id in unlocked_skills:
|
||||
return False
|
||||
|
||||
# Check prerequisites
|
||||
if not skill_node.has_prerequisites_met(unlocked_skills):
|
||||
return False
|
||||
|
||||
# Check tier progression
|
||||
# Must have at least one skill from previous tier unlocked
|
||||
# (except for tier 1 which is always available)
|
||||
if skill_node.tier > 1:
|
||||
has_previous_tier = False
|
||||
for node in self.nodes:
|
||||
if node.tier == skill_node.tier - 1 and node.skill_id in unlocked_skills:
|
||||
has_previous_tier = True
|
||||
break
|
||||
if not has_previous_tier:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_nodes_by_tier(self, tier: int) -> List[SkillNode]:
|
||||
"""
|
||||
Get all skill nodes for a specific tier.
|
||||
|
||||
Args:
|
||||
tier: Tier number (1-5)
|
||||
|
||||
Returns:
|
||||
List of SkillNodes at that tier
|
||||
"""
|
||||
return [node for node in self.nodes if node.tier == tier]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize skill tree to dictionary."""
|
||||
data = asdict(self)
|
||||
data["nodes"] = [node.to_dict() for node in self.nodes]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SkillTree':
|
||||
"""Deserialize skill tree from dictionary."""
|
||||
nodes = [SkillNode.from_dict(n) for n in data.get("nodes", [])]
|
||||
return cls(
|
||||
tree_id=data["tree_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
nodes=nodes,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerClass:
|
||||
"""
|
||||
Represents a character class (Vanguard, Assassin, Arcanist, etc.).
|
||||
|
||||
Each class has unique base stats, multiple skill trees, and starting equipment.
|
||||
|
||||
Attributes:
|
||||
class_id: Unique identifier
|
||||
name: Display name
|
||||
description: Class theme and playstyle
|
||||
base_stats: Starting stats for this class
|
||||
skill_trees: List of skill trees (2+ per class)
|
||||
starting_equipment: List of item_ids for initial equipment
|
||||
starting_abilities: List of ability_ids available from level 1
|
||||
"""
|
||||
|
||||
class_id: str
|
||||
name: str
|
||||
description: str
|
||||
base_stats: Stats
|
||||
skill_trees: List[SkillTree] = field(default_factory=list)
|
||||
starting_equipment: List[str] = field(default_factory=list)
|
||||
starting_abilities: List[str] = field(default_factory=list)
|
||||
|
||||
def get_skill_tree(self, tree_id: str) -> Optional[SkillTree]:
|
||||
"""
|
||||
Get a specific skill tree by ID.
|
||||
|
||||
Args:
|
||||
tree_id: Skill tree identifier
|
||||
|
||||
Returns:
|
||||
SkillTree instance or None if not found
|
||||
"""
|
||||
for tree in self.skill_trees:
|
||||
if tree.tree_id == tree_id:
|
||||
return tree
|
||||
return None
|
||||
|
||||
def get_all_skills(self) -> List[SkillNode]:
|
||||
"""
|
||||
Get all skill nodes from all trees.
|
||||
|
||||
Returns:
|
||||
Flat list of all SkillNodes across all trees
|
||||
"""
|
||||
all_skills = []
|
||||
for tree in self.skill_trees:
|
||||
all_skills.extend(tree.nodes)
|
||||
return all_skills
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize player class to dictionary."""
|
||||
return {
|
||||
"class_id": self.class_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"base_stats": self.base_stats.to_dict(),
|
||||
"skill_trees": [tree.to_dict() for tree in self.skill_trees],
|
||||
"starting_equipment": self.starting_equipment,
|
||||
"starting_abilities": self.starting_abilities,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PlayerClass':
|
||||
"""Deserialize player class from dictionary."""
|
||||
base_stats = Stats.from_dict(data["base_stats"])
|
||||
skill_trees = [SkillTree.from_dict(t) for t in data.get("skill_trees", [])]
|
||||
|
||||
return cls(
|
||||
class_id=data["class_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
base_stats=base_stats,
|
||||
skill_trees=skill_trees,
|
||||
starting_equipment=data.get("starting_equipment", []),
|
||||
starting_abilities=data.get("starting_abilities", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the player class."""
|
||||
return (
|
||||
f"PlayerClass({self.name}, "
|
||||
f"trees={len(self.skill_trees)}, "
|
||||
f"total_skills={len(self.get_all_skills())})"
|
||||
)
|
||||
140
api/app/models/stats.py
Normal file
140
api/app/models/stats.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Character statistics data model.
|
||||
|
||||
This module defines the Stats dataclass which represents a character's core
|
||||
attributes and provides computed properties for derived values like HP and MP.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""
|
||||
Character statistics representing core attributes.
|
||||
|
||||
Attributes:
|
||||
strength: Physical power, affects melee damage
|
||||
dexterity: Agility and precision, affects initiative and evasion
|
||||
constitution: Endurance and health, affects HP and defense
|
||||
intelligence: Magical power, affects spell damage and MP
|
||||
wisdom: Perception and insight, affects magical resistance
|
||||
charisma: Social influence, affects NPC interactions
|
||||
|
||||
Computed Properties:
|
||||
hit_points: Maximum HP = 10 + (constitution × 2)
|
||||
mana_points: Maximum MP = 10 + (intelligence × 2)
|
||||
defense: Physical defense = constitution // 2
|
||||
resistance: Magical resistance = wisdom // 2
|
||||
"""
|
||||
|
||||
strength: int = 10
|
||||
dexterity: int = 10
|
||||
constitution: int = 10
|
||||
intelligence: int = 10
|
||||
wisdom: int = 10
|
||||
charisma: int = 10
|
||||
|
||||
@property
|
||||
def hit_points(self) -> int:
|
||||
"""
|
||||
Calculate maximum hit points based on constitution.
|
||||
|
||||
Formula: 10 + (constitution × 2)
|
||||
|
||||
Returns:
|
||||
Maximum HP value
|
||||
"""
|
||||
return 10 + (self.constitution * 2)
|
||||
|
||||
@property
|
||||
def mana_points(self) -> int:
|
||||
"""
|
||||
Calculate maximum mana points based on intelligence.
|
||||
|
||||
Formula: 10 + (intelligence × 2)
|
||||
|
||||
Returns:
|
||||
Maximum MP value
|
||||
"""
|
||||
return 10 + (self.intelligence * 2)
|
||||
|
||||
@property
|
||||
def defense(self) -> int:
|
||||
"""
|
||||
Calculate physical defense from constitution.
|
||||
|
||||
Formula: constitution // 2
|
||||
|
||||
Returns:
|
||||
Physical defense value (damage reduction)
|
||||
"""
|
||||
return self.constitution // 2
|
||||
|
||||
@property
|
||||
def resistance(self) -> int:
|
||||
"""
|
||||
Calculate magical resistance from wisdom.
|
||||
|
||||
Formula: wisdom // 2
|
||||
|
||||
Returns:
|
||||
Magical resistance value (spell damage reduction)
|
||||
"""
|
||||
return self.wisdom // 2
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize stats to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all stat values
|
||||
"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Stats':
|
||||
"""
|
||||
Deserialize stats from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing stat values
|
||||
|
||||
Returns:
|
||||
Stats instance
|
||||
"""
|
||||
return cls(
|
||||
strength=data.get("strength", 10),
|
||||
dexterity=data.get("dexterity", 10),
|
||||
constitution=data.get("constitution", 10),
|
||||
intelligence=data.get("intelligence", 10),
|
||||
wisdom=data.get("wisdom", 10),
|
||||
charisma=data.get("charisma", 10),
|
||||
)
|
||||
|
||||
def copy(self) -> 'Stats':
|
||||
"""
|
||||
Create a deep copy of this Stats instance.
|
||||
|
||||
Returns:
|
||||
New Stats instance with same values
|
||||
"""
|
||||
return Stats(
|
||||
strength=self.strength,
|
||||
dexterity=self.dexterity,
|
||||
constitution=self.constitution,
|
||||
intelligence=self.intelligence,
|
||||
wisdom=self.wisdom,
|
||||
charisma=self.charisma,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation showing all stats and computed properties."""
|
||||
return (
|
||||
f"Stats(STR={self.strength}, DEX={self.dexterity}, "
|
||||
f"CON={self.constitution}, INT={self.intelligence}, "
|
||||
f"WIS={self.wisdom}, CHA={self.charisma}, "
|
||||
f"HP={self.hit_points}, MP={self.mana_points}, "
|
||||
f"DEF={self.defense}, RES={self.resistance})"
|
||||
)
|
||||
0
api/app/services/__init__.py
Normal file
0
api/app/services/__init__.py
Normal file
320
api/app/services/action_prompt_loader.py
Normal file
320
api/app/services/action_prompt_loader.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Action Prompt Loader Service
|
||||
|
||||
This module provides a service for loading and filtering action prompts from YAML.
|
||||
It implements a singleton pattern to cache loaded prompts in memory.
|
||||
|
||||
Usage:
|
||||
from app.services.action_prompt_loader import ActionPromptLoader
|
||||
|
||||
loader = ActionPromptLoader()
|
||||
loader.load_from_yaml("app/data/action_prompts.yaml")
|
||||
|
||||
# Get available actions for a user at a location
|
||||
actions = loader.get_available_actions(
|
||||
user_tier=UserTier.FREE,
|
||||
location_type=LocationType.TOWN
|
||||
)
|
||||
|
||||
# Get specific action
|
||||
action = loader.get_action_by_id("ask_locals")
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
import yaml
|
||||
|
||||
from app.models.action_prompt import ActionPrompt, LocationType
|
||||
from app.ai.model_selector import UserTier
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class ActionPromptLoaderError(Exception):
|
||||
"""Base exception for action prompt loader errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ActionPromptNotFoundError(ActionPromptLoaderError):
|
||||
"""Raised when a requested action prompt is not found."""
|
||||
pass
|
||||
|
||||
|
||||
class ActionPromptLoader:
|
||||
"""
|
||||
Service for loading and filtering action prompts.
|
||||
|
||||
This class loads action prompts from YAML files and provides methods
|
||||
to filter them based on user tier and location type.
|
||||
|
||||
Uses singleton pattern to cache loaded prompts in memory.
|
||||
|
||||
Attributes:
|
||||
_prompts: Dictionary of loaded action prompts keyed by prompt_id
|
||||
_loaded: Flag indicating if prompts have been loaded
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_prompts: Dict[str, ActionPrompt] = {}
|
||||
_loaded: bool = False
|
||||
|
||||
def __new__(cls):
|
||||
"""Implement singleton pattern."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._prompts = {}
|
||||
cls._instance._loaded = False
|
||||
return cls._instance
|
||||
|
||||
def load_from_yaml(self, filepath: str) -> int:
|
||||
"""
|
||||
Load action prompts from a YAML file.
|
||||
|
||||
Args:
|
||||
filepath: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Number of prompts loaded
|
||||
|
||||
Raises:
|
||||
ActionPromptLoaderError: If file cannot be read or parsed
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
logger.error("Action prompts file not found", filepath=filepath)
|
||||
raise ActionPromptLoaderError(f"File not found: {filepath}")
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Failed to parse YAML", filepath=filepath, error=str(e))
|
||||
raise ActionPromptLoaderError(f"Invalid YAML in {filepath}: {e}")
|
||||
|
||||
except IOError as e:
|
||||
logger.error("Failed to read file", filepath=filepath, error=str(e))
|
||||
raise ActionPromptLoaderError(f"Cannot read {filepath}: {e}")
|
||||
|
||||
if not data or 'action_prompts' not in data:
|
||||
logger.error("No action_prompts key in YAML", filepath=filepath)
|
||||
raise ActionPromptLoaderError(f"Missing 'action_prompts' key in {filepath}")
|
||||
|
||||
# Clear existing prompts
|
||||
self._prompts = {}
|
||||
|
||||
# Parse each prompt
|
||||
prompts_data = data['action_prompts']
|
||||
errors = []
|
||||
|
||||
for i, prompt_data in enumerate(prompts_data):
|
||||
try:
|
||||
prompt = ActionPrompt.from_dict(prompt_data)
|
||||
self._prompts[prompt.prompt_id] = prompt
|
||||
|
||||
except (ValueError, KeyError) as e:
|
||||
errors.append(f"Prompt {i}: {e}")
|
||||
logger.warning(
|
||||
"Failed to parse action prompt",
|
||||
index=i,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
if errors:
|
||||
logger.warning(
|
||||
"Some action prompts failed to load",
|
||||
error_count=len(errors),
|
||||
errors=errors
|
||||
)
|
||||
|
||||
self._loaded = True
|
||||
loaded_count = len(self._prompts)
|
||||
|
||||
logger.info(
|
||||
"Action prompts loaded",
|
||||
filepath=filepath,
|
||||
count=loaded_count,
|
||||
errors=len(errors)
|
||||
)
|
||||
|
||||
return loaded_count
|
||||
|
||||
def get_all_actions(self) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get all loaded action prompts.
|
||||
|
||||
Returns:
|
||||
List of all action prompts
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
return list(self._prompts.values())
|
||||
|
||||
def get_action_by_id(self, prompt_id: str) -> ActionPrompt:
|
||||
"""
|
||||
Get a specific action prompt by ID.
|
||||
|
||||
Args:
|
||||
prompt_id: The unique identifier of the action
|
||||
|
||||
Returns:
|
||||
The ActionPrompt object
|
||||
|
||||
Raises:
|
||||
ActionPromptNotFoundError: If action not found
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if prompt_id not in self._prompts:
|
||||
logger.warning("Action prompt not found", prompt_id=prompt_id)
|
||||
raise ActionPromptNotFoundError(f"Action prompt '{prompt_id}' not found")
|
||||
|
||||
return self._prompts[prompt_id]
|
||||
|
||||
def get_available_actions(
|
||||
self,
|
||||
user_tier: UserTier,
|
||||
location_type: LocationType
|
||||
) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get actions available to a user at a specific location.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
location_type: The current location type
|
||||
|
||||
Returns:
|
||||
List of available action prompts
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
available = []
|
||||
for prompt in self._prompts.values():
|
||||
if prompt.is_available(user_tier, location_type):
|
||||
available.append(prompt)
|
||||
|
||||
logger.debug(
|
||||
"Filtered available actions",
|
||||
user_tier=user_tier.value,
|
||||
location_type=location_type.value,
|
||||
count=len(available)
|
||||
)
|
||||
|
||||
return available
|
||||
|
||||
def get_actions_by_tier(self, user_tier: UserTier) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get all actions available to a user tier (ignoring location).
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
List of action prompts available to the tier
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
available = []
|
||||
for prompt in self._prompts.values():
|
||||
if prompt._tier_meets_requirement(user_tier):
|
||||
available.append(prompt)
|
||||
|
||||
return available
|
||||
|
||||
def get_actions_by_category(self, category: str) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get all actions in a specific category.
|
||||
|
||||
Args:
|
||||
category: The action category (e.g., "ask_question", "explore")
|
||||
|
||||
Returns:
|
||||
List of action prompts in the category
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
return [
|
||||
prompt for prompt in self._prompts.values()
|
||||
if prompt.category.value == category
|
||||
]
|
||||
|
||||
def get_locked_actions(
|
||||
self,
|
||||
user_tier: UserTier,
|
||||
location_type: LocationType
|
||||
) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get actions that are locked due to tier restrictions.
|
||||
|
||||
Used to show locked actions with upgrade prompts in UI.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
location_type: The current location type
|
||||
|
||||
Returns:
|
||||
List of locked action prompts
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
locked = []
|
||||
for prompt in self._prompts.values():
|
||||
# Must match location but be tier-locked
|
||||
if prompt._location_matches_filter(location_type) and prompt.is_locked(user_tier):
|
||||
locked.append(prompt)
|
||||
|
||||
return locked
|
||||
|
||||
def reload(self, filepath: str) -> int:
|
||||
"""
|
||||
Force reload prompts from YAML file.
|
||||
|
||||
Args:
|
||||
filepath: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Number of prompts loaded
|
||||
"""
|
||||
self._loaded = False
|
||||
return self.load_from_yaml(filepath)
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if prompts have been loaded."""
|
||||
return self._loaded
|
||||
|
||||
def get_prompt_count(self) -> int:
|
||||
"""Get the number of loaded prompts."""
|
||||
return len(self._prompts)
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""
|
||||
Ensure prompts are loaded, auto-load from default path if not.
|
||||
|
||||
Raises:
|
||||
ActionPromptLoaderError: If prompts cannot be loaded
|
||||
"""
|
||||
if not self._loaded:
|
||||
# Try default path
|
||||
default_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..', 'data', 'action_prompts.yaml'
|
||||
)
|
||||
default_path = os.path.normpath(default_path)
|
||||
|
||||
if os.path.exists(default_path):
|
||||
self.load_from_yaml(default_path)
|
||||
else:
|
||||
raise ActionPromptLoaderError(
|
||||
"Action prompts not loaded. Call load_from_yaml() first."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""
|
||||
Reset the singleton instance.
|
||||
|
||||
Primarily for testing purposes.
|
||||
"""
|
||||
cls._instance = None
|
||||
588
api/app/services/appwrite_service.py
Normal file
588
api/app/services/appwrite_service.py
Normal file
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Appwrite Service Wrapper
|
||||
|
||||
This module provides a wrapper around the Appwrite SDK for handling user authentication,
|
||||
session management, and user data operations. It abstracts Appwrite's API to provide
|
||||
a clean interface for the application.
|
||||
|
||||
Usage:
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
|
||||
# Initialize service
|
||||
service = AppwriteService()
|
||||
|
||||
# Register a new user
|
||||
user = service.register_user(
|
||||
email="player@example.com",
|
||||
password="SecurePass123!",
|
||||
name="Brave Adventurer"
|
||||
)
|
||||
|
||||
# Login
|
||||
session = service.login_user(
|
||||
email="player@example.com",
|
||||
password="SecurePass123!"
|
||||
)
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.account import Account
|
||||
from appwrite.services.users import Users
|
||||
from appwrite.exception import AppwriteException
|
||||
from appwrite.id import ID
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserData:
|
||||
"""
|
||||
Data class representing a user in the system.
|
||||
|
||||
Attributes:
|
||||
id: Unique user identifier
|
||||
email: User's email address
|
||||
name: User's display name
|
||||
email_verified: Whether email has been verified
|
||||
tier: User's subscription tier (free, basic, premium, elite)
|
||||
created_at: When the user account was created
|
||||
updated_at: When the user account was last updated
|
||||
"""
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
email_verified: bool
|
||||
tier: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert user data to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"email": self.email,
|
||||
"name": self.name,
|
||||
"email_verified": self.email_verified,
|
||||
"tier": self.tier,
|
||||
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
|
||||
"updated_at": self.updated_at.isoformat() if isinstance(self.updated_at, datetime) else self.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionData:
|
||||
"""
|
||||
Data class representing a user session.
|
||||
|
||||
Attributes:
|
||||
session_id: Unique session identifier
|
||||
user_id: User ID associated with this session
|
||||
provider: Authentication provider (email, oauth, etc.)
|
||||
expire: When the session expires
|
||||
"""
|
||||
session_id: str
|
||||
user_id: str
|
||||
provider: str
|
||||
expire: datetime
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert session data to dictionary."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"provider": self.provider,
|
||||
"expire": self.expire.isoformat() if isinstance(self.expire, datetime) else self.expire,
|
||||
}
|
||||
|
||||
|
||||
class AppwriteService:
|
||||
"""
|
||||
Service class for interacting with Appwrite authentication and user management.
|
||||
|
||||
This class provides methods for:
|
||||
- User registration and email verification
|
||||
- User login and logout
|
||||
- Session management
|
||||
- Password reset
|
||||
- User tier management
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the Appwrite service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key (for server-side operations)
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize services
|
||||
self.account = Account(self.client)
|
||||
self.users = Users(self.client)
|
||||
|
||||
logger.info("Appwrite service initialized", endpoint=self.endpoint, project_id=self.project_id)
|
||||
|
||||
def register_user(self, email: str, password: str, name: str) -> UserData:
|
||||
"""
|
||||
Register a new user account.
|
||||
|
||||
This method:
|
||||
1. Creates a new user in Appwrite Auth
|
||||
2. Sets the user's tier to 'free' in preferences
|
||||
3. Triggers email verification
|
||||
4. Returns user data
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
password: User's password (will be hashed by Appwrite)
|
||||
name: User's display name
|
||||
|
||||
Returns:
|
||||
UserData object with user information
|
||||
|
||||
Raises:
|
||||
AppwriteException: If registration fails (e.g., email already exists)
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting to register new user", email=email, name=name)
|
||||
|
||||
# Generate unique user ID
|
||||
user_id = ID.unique()
|
||||
|
||||
# Create user account
|
||||
user = self.users.create(
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
password=password,
|
||||
name=name
|
||||
)
|
||||
|
||||
logger.info("User created successfully", user_id=user['$id'], email=email)
|
||||
|
||||
# Set default tier to 'free' in user preferences
|
||||
self.users.update_prefs(
|
||||
user_id=user['$id'],
|
||||
prefs={
|
||||
'tier': 'free',
|
||||
'tier_updated_at': datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("User tier set to 'free'", user_id=user['$id'])
|
||||
|
||||
# Note: Email verification is handled by Appwrite automatically
|
||||
# when email templates are configured in the Appwrite console.
|
||||
# For server-side user creation, verification emails are sent
|
||||
# automatically if the email provider is configured.
|
||||
#
|
||||
# To manually trigger verification, users can use the Account service
|
||||
# (client-side) after logging in, or configure email verification
|
||||
# settings in the Appwrite console.
|
||||
|
||||
logger.info("User created, email verification handled by Appwrite", user_id=user['$id'], email=email)
|
||||
|
||||
# Return user data
|
||||
return self._user_to_userdata(user)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to register user", email=email, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def login_user(self, email: str, password: str) -> tuple[SessionData, UserData]:
|
||||
"""
|
||||
Authenticate a user and create a session.
|
||||
|
||||
For server-side authentication, we create a temporary client with user
|
||||
credentials to verify them, then create a session using the server SDK.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
password: User's password
|
||||
|
||||
Returns:
|
||||
Tuple of (SessionData, UserData)
|
||||
|
||||
Raises:
|
||||
AppwriteException: If login fails (invalid credentials, etc.)
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting user login", email=email)
|
||||
|
||||
# Use admin client (with API key) to create session
|
||||
# This is required to get the session secret in the response
|
||||
from appwrite.services.account import Account
|
||||
|
||||
admin_account = Account(self.client) # self.client already has API key set
|
||||
|
||||
# Create email/password session using admin client
|
||||
# When using admin client, the 'secret' field is populated in the response
|
||||
user_session = admin_account.create_email_password_session(
|
||||
email=email,
|
||||
password=password
|
||||
)
|
||||
|
||||
logger.info("Session created successfully",
|
||||
user_id=user_session['userId'],
|
||||
session_id=user_session['$id'])
|
||||
|
||||
# Extract session secret from response
|
||||
# Admin client populates this field, unlike regular client
|
||||
session_secret = user_session.get('secret', '')
|
||||
|
||||
if not session_secret:
|
||||
logger.error("Session secret not found in response - this should not happen with admin client")
|
||||
raise AppwriteException("Failed to get session secret", code=500)
|
||||
|
||||
# Get user data using server SDK
|
||||
user = self.users.get(user_id=user_session['userId'])
|
||||
|
||||
# Convert to our data classes
|
||||
session_data = SessionData(
|
||||
session_id=session_secret, # Use the secret, not the session ID
|
||||
user_id=user_session['userId'],
|
||||
provider=user_session['provider'],
|
||||
expire=datetime.fromisoformat(user_session['expire'].replace('Z', '+00:00'))
|
||||
)
|
||||
|
||||
user_data = self._user_to_userdata(user)
|
||||
|
||||
return session_data, user_data
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to login user", email=email, error=str(e), code=e.code)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during login", email=email, error=str(e), exc_info=True)
|
||||
raise AppwriteException(str(e), code=500)
|
||||
|
||||
def logout_user(self, session_id: str) -> bool:
|
||||
"""
|
||||
Log out a user by deleting their session.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to delete
|
||||
|
||||
Returns:
|
||||
True if logout successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If logout fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting to logout user", session_id=session_id)
|
||||
|
||||
# For server-side, we need to delete the session using Users service
|
||||
# First get the session to find the user_id
|
||||
# Note: Appwrite doesn't have a direct server-side session delete by session_id
|
||||
# We'll use a workaround by creating a client with the session and deleting it
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.account import Account
|
||||
|
||||
# Create client with the session
|
||||
session_client = Client()
|
||||
session_client.set_endpoint(self.endpoint)
|
||||
session_client.set_project(self.project_id)
|
||||
session_client.set_session(session_id)
|
||||
|
||||
session_account = Account(session_client)
|
||||
|
||||
# Delete the current session
|
||||
session_account.delete_session('current')
|
||||
|
||||
logger.info("User logged out successfully", session_id=session_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to logout user", session_id=session_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def verify_email(self, user_id: str, secret: str) -> bool:
|
||||
"""
|
||||
Verify a user's email address.
|
||||
|
||||
Note: Email verification with server-side SDK requires updating
|
||||
the user's emailVerification status directly, or using Appwrite's
|
||||
built-in verification flow through the Account service (client-side).
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
secret: Verification secret from email link (not validated server-side)
|
||||
|
||||
Returns:
|
||||
True if verification successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If verification fails (invalid/expired secret)
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting to verify email", user_id=user_id, secret_provided=bool(secret))
|
||||
|
||||
# For server-side verification, we update the user's email verification status
|
||||
# The secret validation should be done by Appwrite's verification flow
|
||||
# For now, we'll mark the email as verified
|
||||
# In production, you should validate the secret token before updating
|
||||
self.users.update_email_verification(user_id=user_id, email_verification=True)
|
||||
|
||||
logger.info("Email verified successfully", user_id=user_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to verify email", user_id=user_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def request_password_reset(self, email: str) -> bool:
|
||||
"""
|
||||
Request a password reset for a user.
|
||||
|
||||
This sends a password reset email to the user. For security,
|
||||
it always returns True even if the email doesn't exist.
|
||||
|
||||
Note: Password reset is handled through Appwrite's built-in Account
|
||||
service recovery flow. For server-side operations, we would need to
|
||||
create a password recovery token manually.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
|
||||
Returns:
|
||||
Always True (for security - don't reveal if email exists)
|
||||
"""
|
||||
try:
|
||||
logger.info("Password reset requested", email=email)
|
||||
|
||||
# Note: Password reset with server-side SDK requires creating
|
||||
# a recovery token. For now, we'll log this and return success.
|
||||
# In production, configure Appwrite's email templates and use
|
||||
# client-side Account.createRecovery() or implement custom token
|
||||
# generation and email sending.
|
||||
|
||||
logger.warning("Password reset not fully implemented - requires Appwrite email configuration", email=email)
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but still return True for security
|
||||
# Don't reveal whether the email exists
|
||||
logger.warning("Password reset request encountered error", email=email, error=str(e))
|
||||
|
||||
# Always return True to not reveal if email exists
|
||||
return True
|
||||
|
||||
def confirm_password_reset(self, user_id: str, secret: str, password: str) -> bool:
|
||||
"""
|
||||
Confirm a password reset and update the user's password.
|
||||
|
||||
Note: For server-side operations, we update the password directly
|
||||
using the Users service. Secret validation would be handled separately.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
secret: Reset secret from email link (should be validated before calling)
|
||||
password: New password
|
||||
|
||||
Returns:
|
||||
True if password reset successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If reset fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting to reset password", user_id=user_id, secret_provided=bool(secret))
|
||||
|
||||
# For server-side password reset, update the password directly
|
||||
# In production, you should validate the secret token first before calling this
|
||||
# The secret parameter is kept for API compatibility but not validated here
|
||||
self.users.update_password(user_id=user_id, password=password)
|
||||
|
||||
logger.info("Password reset successfully", user_id=user_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to reset password", user_id=user_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def get_user(self, user_id: str) -> UserData:
|
||||
"""
|
||||
Get user data by user ID.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
UserData object
|
||||
|
||||
Raises:
|
||||
AppwriteException: If user not found
|
||||
"""
|
||||
try:
|
||||
user = self.users.get(user_id=user_id)
|
||||
|
||||
return self._user_to_userdata(user)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to fetch user", user_id=user_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def get_session(self, session_id: str) -> SessionData:
|
||||
"""
|
||||
Get session data and validate it's still active.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
SessionData object
|
||||
|
||||
Raises:
|
||||
AppwriteException: If session invalid or expired
|
||||
"""
|
||||
try:
|
||||
# Create a client with the session to validate it
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.account import Account
|
||||
|
||||
session_client = Client()
|
||||
session_client.set_endpoint(self.endpoint)
|
||||
session_client.set_project(self.project_id)
|
||||
session_client.set_session(session_id)
|
||||
|
||||
session_account = Account(session_client)
|
||||
|
||||
# Get the current session (this validates it exists and is active)
|
||||
session = session_account.get_session('current')
|
||||
|
||||
# Check if session is expired
|
||||
expire_time = datetime.fromisoformat(session['expire'].replace('Z', '+00:00'))
|
||||
if expire_time < datetime.now(timezone.utc):
|
||||
logger.warning("Session expired", session_id=session_id, expired_at=expire_time)
|
||||
raise AppwriteException("Session expired", code=401)
|
||||
|
||||
return SessionData(
|
||||
session_id=session['$id'],
|
||||
user_id=session['userId'],
|
||||
provider=session['provider'],
|
||||
expire=expire_time
|
||||
)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to validate session", session_id=session_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def get_user_tier(self, user_id: str) -> str:
|
||||
"""
|
||||
Get the user's subscription tier.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Tier string (free, basic, premium, elite)
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching user tier", user_id=user_id)
|
||||
|
||||
user = self.users.get(user_id=user_id)
|
||||
prefs = user.get('prefs', {})
|
||||
tier = prefs.get('tier', 'free')
|
||||
|
||||
logger.debug("User tier retrieved", user_id=user_id, tier=tier)
|
||||
return tier
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to fetch user tier", user_id=user_id, error=str(e), code=e.code)
|
||||
# Default to free tier on error
|
||||
return 'free'
|
||||
|
||||
def set_user_tier(self, user_id: str, tier: str) -> bool:
|
||||
"""
|
||||
Update the user's subscription tier.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
tier: New tier (free, basic, premium, elite)
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If update fails
|
||||
ValueError: If tier is invalid
|
||||
"""
|
||||
valid_tiers = ['free', 'basic', 'premium', 'elite']
|
||||
if tier not in valid_tiers:
|
||||
raise ValueError(f"Invalid tier: {tier}. Must be one of {valid_tiers}")
|
||||
|
||||
try:
|
||||
logger.info("Updating user tier", user_id=user_id, new_tier=tier)
|
||||
|
||||
# Get current preferences
|
||||
user = self.users.get(user_id=user_id)
|
||||
prefs = user.get('prefs', {})
|
||||
|
||||
# Update tier
|
||||
prefs['tier'] = tier
|
||||
prefs['tier_updated_at'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
self.users.update_prefs(user_id=user_id, prefs=prefs)
|
||||
|
||||
logger.info("User tier updated successfully", user_id=user_id, tier=tier)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to update user tier", user_id=user_id, tier=tier, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def _user_to_userdata(self, user: Dict[str, Any]) -> UserData:
|
||||
"""
|
||||
Convert Appwrite user object to UserData dataclass.
|
||||
|
||||
Args:
|
||||
user: Appwrite user dictionary
|
||||
|
||||
Returns:
|
||||
UserData object
|
||||
"""
|
||||
# Get tier from preferences, default to 'free'
|
||||
prefs = user.get('prefs', {})
|
||||
tier = prefs.get('tier', 'free')
|
||||
|
||||
# Parse timestamps
|
||||
created_at = user.get('$createdAt', datetime.now(timezone.utc).isoformat())
|
||||
updated_at = user.get('$updatedAt', datetime.now(timezone.utc).isoformat())
|
||||
|
||||
if isinstance(created_at, str):
|
||||
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||
if isinstance(updated_at, str):
|
||||
updated_at = datetime.fromisoformat(updated_at.replace('Z', '+00:00'))
|
||||
|
||||
return UserData(
|
||||
id=user['$id'],
|
||||
email=user['email'],
|
||||
name=user['name'],
|
||||
email_verified=user.get('emailVerification', False),
|
||||
tier=tier,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
1049
api/app/services/character_service.py
Normal file
1049
api/app/services/character_service.py
Normal file
File diff suppressed because it is too large
Load Diff
277
api/app/services/class_loader.py
Normal file
277
api/app/services/class_loader.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
ClassLoader service for loading player class definitions from YAML files.
|
||||
|
||||
This service reads class configuration files and converts them into PlayerClass
|
||||
dataclass instances, providing caching for performance.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.skills import PlayerClass, SkillTree, SkillNode
|
||||
from app.models.stats import Stats
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ClassLoader:
|
||||
"""
|
||||
Loads player class definitions from YAML configuration files.
|
||||
|
||||
This allows game designers to define classes and skill trees without touching code.
|
||||
All class definitions are stored in /app/data/classes/ as YAML files.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the class loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing class YAML files.
|
||||
Defaults to /app/data/classes/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/classes relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "classes")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._class_cache: Dict[str, PlayerClass] = {}
|
||||
|
||||
logger.info("ClassLoader initialized", data_dir=str(self.data_dir))
|
||||
|
||||
def load_class(self, class_id: str) -> Optional[PlayerClass]:
|
||||
"""
|
||||
Load a single player class by ID.
|
||||
|
||||
Args:
|
||||
class_id: Unique class identifier (e.g., "vanguard")
|
||||
|
||||
Returns:
|
||||
PlayerClass instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if class_id in self._class_cache:
|
||||
logger.debug("Class loaded from cache", class_id=class_id)
|
||||
return self._class_cache[class_id]
|
||||
|
||||
# Construct file path
|
||||
file_path = self.data_dir / f"{class_id}.yaml"
|
||||
|
||||
if not file_path.exists():
|
||||
logger.warning("Class file not found", class_id=class_id, file_path=str(file_path))
|
||||
return None
|
||||
|
||||
try:
|
||||
# Load YAML file
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Parse into PlayerClass
|
||||
player_class = self._parse_class_data(data)
|
||||
|
||||
# Cache the result
|
||||
self._class_cache[class_id] = player_class
|
||||
|
||||
logger.info("Class loaded successfully", class_id=class_id)
|
||||
return player_class
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load class", class_id=class_id, error=str(e))
|
||||
return None
|
||||
|
||||
def load_all_classes(self) -> List[PlayerClass]:
|
||||
"""
|
||||
Load all player classes from the data directory.
|
||||
|
||||
Returns:
|
||||
List of PlayerClass instances
|
||||
"""
|
||||
classes = []
|
||||
|
||||
# Find all YAML files in the directory
|
||||
if not self.data_dir.exists():
|
||||
logger.error("Class data directory does not exist", data_dir=str(self.data_dir))
|
||||
return classes
|
||||
|
||||
for file_path in self.data_dir.glob("*.yaml"):
|
||||
class_id = file_path.stem # Get filename without extension
|
||||
player_class = self.load_class(class_id)
|
||||
if player_class:
|
||||
classes.append(player_class)
|
||||
|
||||
logger.info("All classes loaded", count=len(classes))
|
||||
return classes
|
||||
|
||||
def get_class_by_id(self, class_id: str) -> Optional[PlayerClass]:
|
||||
"""
|
||||
Get a player class by ID (alias for load_class).
|
||||
|
||||
Args:
|
||||
class_id: Unique class identifier
|
||||
|
||||
Returns:
|
||||
PlayerClass instance or None if not found
|
||||
"""
|
||||
return self.load_class(class_id)
|
||||
|
||||
def get_all_class_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available class IDs.
|
||||
|
||||
Returns:
|
||||
List of class IDs (e.g., ["vanguard", "assassin", "arcanist"])
|
||||
"""
|
||||
if not self.data_dir.exists():
|
||||
return []
|
||||
|
||||
return [file_path.stem for file_path in self.data_dir.glob("*.yaml")]
|
||||
|
||||
def reload_class(self, class_id: str) -> Optional[PlayerClass]:
|
||||
"""
|
||||
Force reload a class from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when class definitions change.
|
||||
|
||||
Args:
|
||||
class_id: Unique class identifier
|
||||
|
||||
Returns:
|
||||
PlayerClass instance or None if not found
|
||||
"""
|
||||
# Remove from cache if present
|
||||
if class_id in self._class_cache:
|
||||
del self._class_cache[class_id]
|
||||
|
||||
return self.load_class(class_id)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the class cache. Useful for testing."""
|
||||
self._class_cache.clear()
|
||||
logger.info("Class cache cleared")
|
||||
|
||||
def _parse_class_data(self, data: Dict) -> PlayerClass:
|
||||
"""
|
||||
Parse YAML data into a PlayerClass dataclass.
|
||||
|
||||
Args:
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
PlayerClass instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["class_id", "name", "description", "base_stats", "skill_trees"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Parse base stats
|
||||
base_stats = Stats(**data["base_stats"])
|
||||
|
||||
# Parse skill trees
|
||||
skill_trees = []
|
||||
for tree_data in data["skill_trees"]:
|
||||
skill_tree = self._parse_skill_tree(tree_data)
|
||||
skill_trees.append(skill_tree)
|
||||
|
||||
# Get optional fields
|
||||
starting_equipment = data.get("starting_equipment", [])
|
||||
starting_abilities = data.get("starting_abilities", [])
|
||||
|
||||
# Create PlayerClass instance
|
||||
player_class = PlayerClass(
|
||||
class_id=data["class_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
base_stats=base_stats,
|
||||
skill_trees=skill_trees,
|
||||
starting_equipment=starting_equipment,
|
||||
starting_abilities=starting_abilities
|
||||
)
|
||||
|
||||
return player_class
|
||||
|
||||
def _parse_skill_tree(self, tree_data: Dict) -> SkillTree:
|
||||
"""
|
||||
Parse a skill tree from YAML data.
|
||||
|
||||
Args:
|
||||
tree_data: Dictionary containing skill tree data
|
||||
|
||||
Returns:
|
||||
SkillTree instance
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["tree_id", "name", "description", "nodes"]
|
||||
for field in required_fields:
|
||||
if field not in tree_data:
|
||||
raise ValueError(f"Missing required field in skill tree: {field}")
|
||||
|
||||
# Parse skill nodes
|
||||
nodes = []
|
||||
for node_data in tree_data["nodes"]:
|
||||
skill_node = self._parse_skill_node(node_data)
|
||||
nodes.append(skill_node)
|
||||
|
||||
# Create SkillTree instance
|
||||
skill_tree = SkillTree(
|
||||
tree_id=tree_data["tree_id"],
|
||||
name=tree_data["name"],
|
||||
description=tree_data["description"],
|
||||
nodes=nodes
|
||||
)
|
||||
|
||||
return skill_tree
|
||||
|
||||
def _parse_skill_node(self, node_data: Dict) -> SkillNode:
|
||||
"""
|
||||
Parse a skill node from YAML data.
|
||||
|
||||
Args:
|
||||
node_data: Dictionary containing skill node data
|
||||
|
||||
Returns:
|
||||
SkillNode instance
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["skill_id", "name", "description", "tier", "effects"]
|
||||
for field in required_fields:
|
||||
if field not in node_data:
|
||||
raise ValueError(f"Missing required field in skill node: {field}")
|
||||
|
||||
# Create SkillNode instance
|
||||
skill_node = SkillNode(
|
||||
skill_id=node_data["skill_id"],
|
||||
name=node_data["name"],
|
||||
description=node_data["description"],
|
||||
tier=node_data["tier"],
|
||||
prerequisites=node_data.get("prerequisites", []),
|
||||
effects=node_data.get("effects", {}),
|
||||
unlocked=False # Always start locked
|
||||
)
|
||||
|
||||
return skill_node
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_loader_instance: Optional[ClassLoader] = None
|
||||
|
||||
|
||||
def get_class_loader() -> ClassLoader:
|
||||
"""
|
||||
Get the global ClassLoader instance.
|
||||
|
||||
Returns:
|
||||
Singleton ClassLoader instance
|
||||
"""
|
||||
global _loader_instance
|
||||
if _loader_instance is None:
|
||||
_loader_instance = ClassLoader()
|
||||
return _loader_instance
|
||||
709
api/app/services/database_init.py
Normal file
709
api/app/services/database_init.py
Normal file
@@ -0,0 +1,709 @@
|
||||
"""
|
||||
Database Initialization Service.
|
||||
|
||||
This service handles programmatic creation of Appwrite database tables,
|
||||
including schema definition, column creation, and index setup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class DatabaseInitService:
|
||||
"""
|
||||
Service for initializing Appwrite database tables.
|
||||
|
||||
This service provides methods to:
|
||||
- Create tables if they don't exist
|
||||
- Define table schemas (columns/attributes)
|
||||
- Create indexes for efficient querying
|
||||
- Validate existing table structures
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the database initialization service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key
|
||||
- APPWRITE_DATABASE_ID: Appwrite database ID
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize TablesDB service
|
||||
self.tables_db = TablesDB(self.client)
|
||||
|
||||
logger.info("DatabaseInitService initialized", database_id=self.database_id)
|
||||
|
||||
def init_all_tables(self) -> Dict[str, bool]:
|
||||
"""
|
||||
Initialize all application tables.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping table names to success status
|
||||
"""
|
||||
results = {}
|
||||
|
||||
logger.info("Initializing all database tables")
|
||||
|
||||
# Initialize characters table
|
||||
try:
|
||||
self.init_characters_table()
|
||||
results['characters'] = True
|
||||
logger.info("Characters table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize characters table", error=str(e))
|
||||
results['characters'] = False
|
||||
|
||||
# Initialize game_sessions table
|
||||
try:
|
||||
self.init_game_sessions_table()
|
||||
results['game_sessions'] = True
|
||||
logger.info("Game sessions table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize game_sessions table", error=str(e))
|
||||
results['game_sessions'] = False
|
||||
|
||||
# Initialize ai_usage_logs table
|
||||
try:
|
||||
self.init_ai_usage_logs_table()
|
||||
results['ai_usage_logs'] = True
|
||||
logger.info("AI usage logs table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize ai_usage_logs table", error=str(e))
|
||||
results['ai_usage_logs'] = False
|
||||
|
||||
success_count = sum(1 for v in results.values() if v)
|
||||
total_count = len(results)
|
||||
|
||||
logger.info("Table initialization complete",
|
||||
success=success_count,
|
||||
total=total_count,
|
||||
results=results)
|
||||
|
||||
return results
|
||||
|
||||
def init_characters_table(self) -> bool:
|
||||
"""
|
||||
Initialize the characters table.
|
||||
|
||||
Table schema:
|
||||
- userId (string, required): Owner's user ID
|
||||
- characterData (string, required): JSON-serialized character data
|
||||
- is_active (boolean, default=True): Soft delete flag
|
||||
- created_at (datetime): Auto-managed creation timestamp
|
||||
- updated_at (datetime): Auto-managed update timestamp
|
||||
|
||||
Indexes:
|
||||
- userId: For general user queries
|
||||
- userId + is_active: Composite index for efficiently fetching active characters
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'characters'
|
||||
|
||||
logger.info("Initializing characters table", table_id=table_id)
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
try:
|
||||
self.tables_db.get_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id
|
||||
)
|
||||
logger.info("Characters table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("Characters table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating characters table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='Characters'
|
||||
)
|
||||
logger.info("Characters table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='userId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='characterData',
|
||||
column_type='string',
|
||||
size=65535, # Large text field for JSON data
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='is_active',
|
||||
column_type='boolean',
|
||||
required=False, # Cannot be required if we want a default value
|
||||
default=True
|
||||
)
|
||||
|
||||
# Note: created_at and updated_at are auto-managed by DatabaseService
|
||||
# through the _parse_row method and timestamp updates
|
||||
|
||||
# Wait for columns to fully propagate in Appwrite before creating indexes
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes for efficient querying
|
||||
# Note: Individual userId index for general user queries
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId',
|
||||
index_type='key',
|
||||
attributes=['userId']
|
||||
)
|
||||
|
||||
# Composite index for the most common query pattern:
|
||||
# Query.equal('userId', user_id) + Query.equal('is_active', True)
|
||||
# This single composite index covers both conditions efficiently
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId_is_active',
|
||||
index_type='key',
|
||||
attributes=['userId', 'is_active']
|
||||
)
|
||||
|
||||
logger.info("Characters table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize characters table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def init_game_sessions_table(self) -> bool:
|
||||
"""
|
||||
Initialize the game_sessions table.
|
||||
|
||||
Table schema:
|
||||
- userId (string, required): Owner's user ID
|
||||
- characterId (string, required): Character ID for this session
|
||||
- sessionData (string, required): JSON-serialized session data
|
||||
- status (string, required): Session status (active, completed, abandoned)
|
||||
- sessionType (string, required): Session type (solo, multiplayer)
|
||||
|
||||
Indexes:
|
||||
- userId: For user session queries
|
||||
- userId + status: For active session queries
|
||||
- characterId: For character session lookups
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'game_sessions'
|
||||
|
||||
logger.info("Initializing game_sessions table", table_id=table_id)
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
try:
|
||||
self.tables_db.get_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id
|
||||
)
|
||||
logger.info("Game sessions table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("Game sessions table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating game_sessions table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='Game Sessions'
|
||||
)
|
||||
logger.info("Game sessions table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='userId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='characterId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='sessionData',
|
||||
column_type='string',
|
||||
size=65535, # Large text field for JSON data
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='status',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='sessionType',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
# Wait for columns to fully propagate
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId',
|
||||
index_type='key',
|
||||
attributes=['userId']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId_status',
|
||||
index_type='key',
|
||||
attributes=['userId', 'status']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_characterId',
|
||||
index_type='key',
|
||||
attributes=['characterId']
|
||||
)
|
||||
|
||||
logger.info("Game sessions table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize game_sessions table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def init_ai_usage_logs_table(self) -> bool:
|
||||
"""
|
||||
Initialize the ai_usage_logs table for tracking AI API usage and costs.
|
||||
|
||||
Table schema:
|
||||
- user_id (string, required): User who made the request
|
||||
- timestamp (string, required): ISO timestamp of the request
|
||||
- model (string, required): Model identifier
|
||||
- tokens_input (integer, required): Input token count
|
||||
- tokens_output (integer, required): Output token count
|
||||
- tokens_total (integer, required): Total token count
|
||||
- estimated_cost (float, required): Estimated cost in USD
|
||||
- task_type (string, required): Type of task
|
||||
- session_id (string, optional): Game session ID
|
||||
- character_id (string, optional): Character ID
|
||||
- request_duration_ms (integer): Request duration in milliseconds
|
||||
- success (boolean): Whether request succeeded
|
||||
- error_message (string, optional): Error message if failed
|
||||
|
||||
Indexes:
|
||||
- user_id: For user usage queries
|
||||
- timestamp: For date range queries
|
||||
- user_id + timestamp: Composite for user date range queries
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'ai_usage_logs'
|
||||
|
||||
logger.info("Initializing ai_usage_logs table", table_id=table_id)
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
try:
|
||||
self.tables_db.get_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id
|
||||
)
|
||||
logger.info("AI usage logs table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("AI usage logs table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating ai_usage_logs table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='AI Usage Logs'
|
||||
)
|
||||
logger.info("AI usage logs table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='user_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='timestamp',
|
||||
column_type='string',
|
||||
size=50, # ISO timestamp format
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='model',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_input',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_output',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_total',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='estimated_cost',
|
||||
column_type='float',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='task_type',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='session_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=False
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='character_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=False
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='request_duration_ms',
|
||||
column_type='integer',
|
||||
required=False,
|
||||
default=0
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='success',
|
||||
column_type='boolean',
|
||||
required=False,
|
||||
default=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='error_message',
|
||||
column_type='string',
|
||||
size=1000,
|
||||
required=False
|
||||
)
|
||||
|
||||
# Wait for columns to fully propagate
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_user_id',
|
||||
index_type='key',
|
||||
attributes=['user_id']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_timestamp',
|
||||
index_type='key',
|
||||
attributes=['timestamp']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_user_id_timestamp',
|
||||
index_type='key',
|
||||
attributes=['user_id', 'timestamp']
|
||||
)
|
||||
|
||||
logger.info("AI usage logs table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize ai_usage_logs table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _create_column(
|
||||
self,
|
||||
table_id: str,
|
||||
column_id: str,
|
||||
column_type: str,
|
||||
size: Optional[int] = None,
|
||||
required: bool = False,
|
||||
default: Optional[Any] = None,
|
||||
array: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a column in a table.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
column_id: Column ID
|
||||
column_type: Column type (string, integer, float, boolean, datetime, email, ip, url)
|
||||
size: Column size (for string types)
|
||||
required: Whether column is required
|
||||
default: Default value
|
||||
array: Whether column is an array
|
||||
|
||||
Returns:
|
||||
Column creation response
|
||||
|
||||
Raises:
|
||||
AppwriteException: If column creation fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating column",
|
||||
table_id=table_id,
|
||||
column_id=column_id,
|
||||
column_type=column_type)
|
||||
|
||||
# Build column parameters (Appwrite SDK uses 'key' not 'column_id')
|
||||
params = {
|
||||
'database_id': self.database_id,
|
||||
'table_id': table_id,
|
||||
'key': column_id,
|
||||
'required': required,
|
||||
'array': array
|
||||
}
|
||||
|
||||
if size is not None:
|
||||
params['size'] = size
|
||||
|
||||
if default is not None:
|
||||
params['default'] = default
|
||||
|
||||
# Create column using the appropriate method based on type
|
||||
if column_type == 'string':
|
||||
result = self.tables_db.create_string_column(**params)
|
||||
elif column_type == 'integer':
|
||||
result = self.tables_db.create_integer_column(**params)
|
||||
elif column_type == 'float':
|
||||
result = self.tables_db.create_float_column(**params)
|
||||
elif column_type == 'boolean':
|
||||
result = self.tables_db.create_boolean_column(**params)
|
||||
elif column_type == 'datetime':
|
||||
result = self.tables_db.create_datetime_column(**params)
|
||||
elif column_type == 'email':
|
||||
result = self.tables_db.create_email_column(**params)
|
||||
else:
|
||||
raise ValueError(f"Unsupported column type: {column_type}")
|
||||
|
||||
logger.info("Column created successfully",
|
||||
table_id=table_id,
|
||||
column_id=column_id)
|
||||
|
||||
return result
|
||||
|
||||
except AppwriteException as e:
|
||||
# If column already exists, log warning but don't fail
|
||||
if e.code == 409: # Conflict - column already exists
|
||||
logger.warning("Column already exists",
|
||||
table_id=table_id,
|
||||
column_id=column_id)
|
||||
return {}
|
||||
logger.error("Failed to create column",
|
||||
table_id=table_id,
|
||||
column_id=column_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _create_index(
|
||||
self,
|
||||
table_id: str,
|
||||
index_id: str,
|
||||
index_type: str,
|
||||
attributes: List[str],
|
||||
orders: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an index on a table.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
index_id: Index ID
|
||||
index_type: Index type (key, fulltext, unique)
|
||||
attributes: List of column IDs to index
|
||||
orders: List of sort orders (ASC, DESC) for each attribute
|
||||
|
||||
Returns:
|
||||
Index creation response
|
||||
|
||||
Raises:
|
||||
AppwriteException: If index creation fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating index",
|
||||
table_id=table_id,
|
||||
index_id=index_id,
|
||||
attributes=attributes)
|
||||
|
||||
result = self.tables_db.create_index(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
key=index_id,
|
||||
type=index_type,
|
||||
columns=attributes, # SDK uses 'columns', not 'attributes'
|
||||
orders=orders or ['ASC'] * len(attributes)
|
||||
)
|
||||
|
||||
logger.info("Index created successfully",
|
||||
table_id=table_id,
|
||||
index_id=index_id)
|
||||
|
||||
return result
|
||||
|
||||
except AppwriteException as e:
|
||||
# If index already exists, log warning but don't fail
|
||||
if e.code == 409: # Conflict - index already exists
|
||||
logger.warning("Index already exists",
|
||||
table_id=table_id,
|
||||
index_id=index_id)
|
||||
return {}
|
||||
logger.error("Failed to create index",
|
||||
table_id=table_id,
|
||||
index_id=index_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_init_service_instance: Optional[DatabaseInitService] = None
|
||||
|
||||
|
||||
def get_database_init_service() -> DatabaseInitService:
|
||||
"""
|
||||
Get the global DatabaseInitService instance.
|
||||
|
||||
Returns:
|
||||
Singleton DatabaseInitService instance
|
||||
"""
|
||||
global _init_service_instance
|
||||
if _init_service_instance is None:
|
||||
_init_service_instance = DatabaseInitService()
|
||||
return _init_service_instance
|
||||
|
||||
|
||||
def init_database() -> Dict[str, bool]:
|
||||
"""
|
||||
Convenience function to initialize all database tables.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping table names to success status
|
||||
"""
|
||||
service = get_database_init_service()
|
||||
return service.init_all_tables()
|
||||
441
api/app/services/database_service.py
Normal file
441
api/app/services/database_service.py
Normal file
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
Database Service for Appwrite database operations.
|
||||
|
||||
This service wraps the Appwrite Databases SDK to provide a clean interface
|
||||
for CRUD operations on collections. It handles JSON serialization, error handling,
|
||||
and provides structured logging.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
from appwrite.exception import AppwriteException
|
||||
from appwrite.id import ID
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseRow:
|
||||
"""
|
||||
Represents a row in an Appwrite table.
|
||||
|
||||
Attributes:
|
||||
id: Row ID
|
||||
table_id: Table ID
|
||||
data: Row data (parsed from JSON)
|
||||
created_at: Creation timestamp
|
||||
updated_at: Last update timestamp
|
||||
"""
|
||||
id: str
|
||||
table_id: str
|
||||
data: Dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert row to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"table_id": self.table_id,
|
||||
"data": self.data,
|
||||
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
|
||||
"updated_at": self.updated_at.isoformat() if isinstance(self.updated_at, datetime) else self.updated_at,
|
||||
}
|
||||
|
||||
|
||||
class DatabaseService:
|
||||
"""
|
||||
Service for interacting with Appwrite database tables.
|
||||
|
||||
This service provides methods for:
|
||||
- Creating rows
|
||||
- Reading rows by ID or query
|
||||
- Updating rows
|
||||
- Deleting rows
|
||||
- Querying with filters
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the database service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key
|
||||
- APPWRITE_DATABASE_ID: Appwrite database ID
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize TablesDB service
|
||||
self.tables_db = TablesDB(self.client)
|
||||
|
||||
logger.info("DatabaseService initialized", database_id=self.database_id)
|
||||
|
||||
def create_row(
|
||||
self,
|
||||
table_id: str,
|
||||
data: Dict[str, Any],
|
||||
row_id: Optional[str] = None,
|
||||
permissions: Optional[List[str]] = None
|
||||
) -> DatabaseRow:
|
||||
"""
|
||||
Create a new row in a table.
|
||||
|
||||
Args:
|
||||
table_id: Table ID (e.g., "characters")
|
||||
data: Row data (will be JSON-serialized if needed)
|
||||
row_id: Optional custom row ID (auto-generated if None)
|
||||
permissions: Optional permissions array
|
||||
|
||||
Returns:
|
||||
DatabaseRow with created row
|
||||
|
||||
Raises:
|
||||
AppwriteException: If creation fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating row", table_id=table_id, has_custom_id=bool(row_id))
|
||||
|
||||
# Generate ID if not provided
|
||||
if row_id is None:
|
||||
row_id = ID.unique()
|
||||
|
||||
# Create row (Appwrite manages timestamps automatically via $createdAt/$updatedAt)
|
||||
result = self.tables_db.create_row(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
data=data,
|
||||
permissions=permissions or []
|
||||
)
|
||||
|
||||
logger.info("Row created successfully",
|
||||
table_id=table_id,
|
||||
row_id=result['$id'])
|
||||
|
||||
return self._parse_row(result, table_id)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to create row",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def get_row(self, table_id: str, row_id: str) -> Optional[DatabaseRow]:
|
||||
"""
|
||||
Get a row by ID.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
row_id: Row ID
|
||||
|
||||
Returns:
|
||||
DatabaseRow or None if not found
|
||||
|
||||
Raises:
|
||||
AppwriteException: If retrieval fails (except 404)
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching row", table_id=table_id, row_id=row_id)
|
||||
|
||||
result = self.tables_db.get_row(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
row_id=row_id
|
||||
)
|
||||
|
||||
return self._parse_row(result, table_id)
|
||||
|
||||
except AppwriteException as e:
|
||||
if e.code == 404:
|
||||
logger.warning("Row not found",
|
||||
table_id=table_id,
|
||||
row_id=row_id)
|
||||
return None
|
||||
logger.error("Failed to fetch row",
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def update_row(
|
||||
self,
|
||||
table_id: str,
|
||||
row_id: str,
|
||||
data: Dict[str, Any],
|
||||
permissions: Optional[List[str]] = None
|
||||
) -> DatabaseRow:
|
||||
"""
|
||||
Update an existing row.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
row_id: Row ID
|
||||
data: New row data (partial updates supported)
|
||||
permissions: Optional permissions array
|
||||
|
||||
Returns:
|
||||
DatabaseRow with updated row
|
||||
|
||||
Raises:
|
||||
AppwriteException: If update fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Updating row", table_id=table_id, row_id=row_id)
|
||||
|
||||
# Update row (Appwrite manages timestamps automatically via $updatedAt)
|
||||
result = self.tables_db.update_row(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
data=data,
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
logger.info("Row updated successfully",
|
||||
table_id=table_id,
|
||||
row_id=row_id)
|
||||
|
||||
return self._parse_row(result, table_id)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to update row",
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def delete_row(self, table_id: str, row_id: str) -> bool:
|
||||
"""
|
||||
Delete a row.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
row_id: Row ID
|
||||
|
||||
Returns:
|
||||
True if deletion successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If deletion fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Deleting row", table_id=table_id, row_id=row_id)
|
||||
|
||||
self.tables_db.delete_row(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
row_id=row_id
|
||||
)
|
||||
|
||||
logger.info("Row deleted successfully",
|
||||
table_id=table_id,
|
||||
row_id=row_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to delete row",
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def list_rows(
|
||||
self,
|
||||
table_id: str,
|
||||
queries: Optional[List[str]] = None,
|
||||
limit: int = 25,
|
||||
offset: int = 0
|
||||
) -> List[DatabaseRow]:
|
||||
"""
|
||||
List rows in a table with optional filtering.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
queries: Optional Appwrite query filters
|
||||
limit: Maximum rows to return (default 25, max 100)
|
||||
offset: Number of rows to skip
|
||||
|
||||
Returns:
|
||||
List of DatabaseRow instances
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
logger.debug("Listing rows",
|
||||
table_id=table_id,
|
||||
has_queries=bool(queries),
|
||||
limit=limit,
|
||||
offset=offset)
|
||||
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
queries=queries or []
|
||||
)
|
||||
|
||||
rows = [self._parse_row(row, table_id) for row in result['rows']]
|
||||
|
||||
logger.debug("Rows listed successfully",
|
||||
table_id=table_id,
|
||||
count=len(rows),
|
||||
total=result.get('total', len(rows)))
|
||||
|
||||
return rows
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to list rows",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def count_rows(self, table_id: str, queries: Optional[List[str]] = None) -> int:
|
||||
"""
|
||||
Count rows in a table with optional filtering.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
queries: Optional Appwrite query filters
|
||||
|
||||
Returns:
|
||||
Row count
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
logger.debug("Counting rows", table_id=table_id, has_queries=bool(queries))
|
||||
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
queries=queries or []
|
||||
)
|
||||
|
||||
count = result.get('total', len(result.get('rows', [])))
|
||||
logger.debug("Rows counted", table_id=table_id, count=count)
|
||||
return count
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to count rows",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _parse_row(self, row: Dict[str, Any], table_id: str) -> DatabaseRow:
|
||||
"""
|
||||
Parse Appwrite row into DatabaseRow.
|
||||
|
||||
Args:
|
||||
row: Appwrite row dictionary
|
||||
table_id: Table ID
|
||||
|
||||
Returns:
|
||||
DatabaseRow instance
|
||||
"""
|
||||
# Extract metadata
|
||||
row_id = row['$id']
|
||||
created_at = row.get('$createdAt', datetime.now(timezone.utc).isoformat())
|
||||
updated_at = row.get('$updatedAt', datetime.now(timezone.utc).isoformat())
|
||||
|
||||
# Parse timestamps
|
||||
if isinstance(created_at, str):
|
||||
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||
if isinstance(updated_at, str):
|
||||
updated_at = datetime.fromisoformat(updated_at.replace('Z', '+00:00'))
|
||||
|
||||
# Remove Appwrite metadata from data
|
||||
data = {k: v for k, v in row.items() if not k.startswith('$')}
|
||||
|
||||
return DatabaseRow(
|
||||
id=row_id,
|
||||
table_id=table_id,
|
||||
data=data,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
|
||||
# Backward compatibility aliases (deprecated, use new methods)
|
||||
def create_document(self, collection_id: str, data: Dict[str, Any],
|
||||
document_id: Optional[str] = None,
|
||||
permissions: Optional[List[str]] = None) -> DatabaseRow:
|
||||
"""Deprecated: Use create_row() instead."""
|
||||
logger.warning("create_document() is deprecated, use create_row() instead")
|
||||
return self.create_row(collection_id, data, document_id, permissions)
|
||||
|
||||
def get_document(self, collection_id: str, document_id: str) -> Optional[DatabaseRow]:
|
||||
"""Deprecated: Use get_row() instead."""
|
||||
logger.warning("get_document() is deprecated, use get_row() instead")
|
||||
return self.get_row(collection_id, document_id)
|
||||
|
||||
def update_document(self, collection_id: str, document_id: str,
|
||||
data: Dict[str, Any],
|
||||
permissions: Optional[List[str]] = None) -> DatabaseRow:
|
||||
"""Deprecated: Use update_row() instead."""
|
||||
logger.warning("update_document() is deprecated, use update_row() instead")
|
||||
return self.update_row(collection_id, document_id, data, permissions)
|
||||
|
||||
def delete_document(self, collection_id: str, document_id: str) -> bool:
|
||||
"""Deprecated: Use delete_row() instead."""
|
||||
logger.warning("delete_document() is deprecated, use delete_row() instead")
|
||||
return self.delete_row(collection_id, document_id)
|
||||
|
||||
def list_documents(self, collection_id: str, queries: Optional[List[str]] = None,
|
||||
limit: int = 25, offset: int = 0) -> List[DatabaseRow]:
|
||||
"""Deprecated: Use list_rows() instead."""
|
||||
logger.warning("list_documents() is deprecated, use list_rows() instead")
|
||||
return self.list_rows(collection_id, queries, limit, offset)
|
||||
|
||||
def count_documents(self, collection_id: str, queries: Optional[List[str]] = None) -> int:
|
||||
"""Deprecated: Use count_rows() instead."""
|
||||
logger.warning("count_documents() is deprecated, use count_rows() instead")
|
||||
return self.count_rows(collection_id, queries)
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
DatabaseDocument = DatabaseRow
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_service_instance: Optional[DatabaseService] = None
|
||||
|
||||
|
||||
def get_database_service() -> DatabaseService:
|
||||
"""
|
||||
Get the global DatabaseService instance.
|
||||
|
||||
Returns:
|
||||
Singleton DatabaseService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = DatabaseService()
|
||||
return _service_instance
|
||||
351
api/app/services/item_validator.py
Normal file
351
api/app/services/item_validator.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Item validation service for AI-granted items.
|
||||
|
||||
This module validates and resolves items that the AI grants to players during
|
||||
gameplay, ensuring they meet character requirements and game balance rules.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
import yaml
|
||||
|
||||
from app.models.items import Item
|
||||
from app.models.enums import ItemType
|
||||
from app.models.character import Character
|
||||
from app.ai.response_parser import ItemGrant
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ItemValidationError(Exception):
|
||||
"""
|
||||
Exception raised when an item fails validation.
|
||||
|
||||
Attributes:
|
||||
message: Human-readable error message
|
||||
item_grant: The ItemGrant that failed validation
|
||||
reason: Machine-readable reason code
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, item_grant: ItemGrant, reason: str):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.item_grant = item_grant
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class ItemValidator:
|
||||
"""
|
||||
Validates and resolves items granted by the AI.
|
||||
|
||||
This service:
|
||||
1. Resolves item references (by ID or creates generic items)
|
||||
2. Validates items against character requirements
|
||||
3. Logs validation failures for review
|
||||
"""
|
||||
|
||||
# Map of generic item type strings to ItemType enums
|
||||
TYPE_MAP = {
|
||||
"weapon": ItemType.WEAPON,
|
||||
"armor": ItemType.ARMOR,
|
||||
"consumable": ItemType.CONSUMABLE,
|
||||
"quest_item": ItemType.QUEST_ITEM,
|
||||
}
|
||||
|
||||
def __init__(self, data_path: Optional[Path] = None):
|
||||
"""
|
||||
Initialize the item validator.
|
||||
|
||||
Args:
|
||||
data_path: Path to game data directory. Defaults to app/data/
|
||||
"""
|
||||
if data_path is None:
|
||||
# Default to api/app/data/
|
||||
data_path = Path(__file__).parent.parent / "data"
|
||||
|
||||
self.data_path = data_path
|
||||
self._item_registry: dict[str, dict] = {}
|
||||
self._generic_templates: dict[str, dict] = {}
|
||||
self._load_data()
|
||||
|
||||
logger.info(
|
||||
"ItemValidator initialized",
|
||||
items_loaded=len(self._item_registry),
|
||||
generic_templates_loaded=len(self._generic_templates)
|
||||
)
|
||||
|
||||
def _load_data(self) -> None:
|
||||
"""Load item data from YAML files."""
|
||||
# Load main item registry if it exists
|
||||
items_file = self.data_path / "items.yaml"
|
||||
if items_file.exists():
|
||||
with open(items_file) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
self._item_registry = data.get("items", {})
|
||||
|
||||
# Load generic item templates
|
||||
generic_file = self.data_path / "generic_items.yaml"
|
||||
if generic_file.exists():
|
||||
with open(generic_file) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
self._generic_templates = data.get("templates", {})
|
||||
|
||||
def resolve_item(self, item_grant: ItemGrant) -> Item:
|
||||
"""
|
||||
Resolve an ItemGrant to an actual Item instance.
|
||||
|
||||
For existing items (by item_id), looks up from item registry.
|
||||
For generic items (by name/type), creates a new Item.
|
||||
|
||||
Args:
|
||||
item_grant: The ItemGrant from AI response
|
||||
|
||||
Returns:
|
||||
Resolved Item instance
|
||||
|
||||
Raises:
|
||||
ItemValidationError: If item cannot be resolved
|
||||
"""
|
||||
if item_grant.is_existing_item():
|
||||
return self._resolve_existing_item(item_grant)
|
||||
elif item_grant.is_generic_item():
|
||||
return self._create_generic_item(item_grant)
|
||||
else:
|
||||
raise ItemValidationError(
|
||||
"ItemGrant has neither item_id nor name",
|
||||
item_grant,
|
||||
"INVALID_ITEM_GRANT"
|
||||
)
|
||||
|
||||
def _resolve_existing_item(self, item_grant: ItemGrant) -> Item:
|
||||
"""
|
||||
Look up an existing item by ID.
|
||||
|
||||
Args:
|
||||
item_grant: ItemGrant with item_id set
|
||||
|
||||
Returns:
|
||||
Item instance from registry
|
||||
|
||||
Raises:
|
||||
ItemValidationError: If item not found
|
||||
"""
|
||||
item_id = item_grant.item_id
|
||||
|
||||
if item_id not in self._item_registry:
|
||||
logger.warning(
|
||||
"Item not found in registry",
|
||||
item_id=item_id
|
||||
)
|
||||
raise ItemValidationError(
|
||||
f"Unknown item_id: {item_id}",
|
||||
item_grant,
|
||||
"ITEM_NOT_FOUND"
|
||||
)
|
||||
|
||||
item_data = self._item_registry[item_id]
|
||||
|
||||
# Convert to Item instance
|
||||
return Item.from_dict({
|
||||
"item_id": item_id,
|
||||
**item_data
|
||||
})
|
||||
|
||||
def _create_generic_item(self, item_grant: ItemGrant) -> Item:
|
||||
"""
|
||||
Create a generic item from AI-provided details.
|
||||
|
||||
Generic items are simple items with no special stats,
|
||||
suitable for mundane objects like torches, food, etc.
|
||||
|
||||
Args:
|
||||
item_grant: ItemGrant with name, type, description
|
||||
|
||||
Returns:
|
||||
New Item instance
|
||||
|
||||
Raises:
|
||||
ItemValidationError: If item type is invalid
|
||||
"""
|
||||
# Validate item type
|
||||
item_type_str = (item_grant.item_type or "consumable").lower()
|
||||
if item_type_str not in self.TYPE_MAP:
|
||||
logger.warning(
|
||||
"Invalid item type from AI",
|
||||
item_type=item_type_str,
|
||||
item_name=item_grant.name
|
||||
)
|
||||
# Default to consumable for unknown types
|
||||
item_type_str = "consumable"
|
||||
|
||||
item_type = self.TYPE_MAP[item_type_str]
|
||||
|
||||
# Generate unique ID for this item instance
|
||||
item_id = f"generic_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Check if we have a template for this item name
|
||||
template = self._find_template(item_grant.name or "")
|
||||
|
||||
if template:
|
||||
# Use template values as defaults
|
||||
return Item(
|
||||
item_id=item_id,
|
||||
name=item_grant.name or template.get("name", "Unknown Item"),
|
||||
item_type=item_type,
|
||||
description=item_grant.description or template.get("description", ""),
|
||||
value=item_grant.value or template.get("value", 0),
|
||||
is_tradeable=template.get("is_tradeable", True),
|
||||
required_level=template.get("required_level", 1),
|
||||
)
|
||||
else:
|
||||
# Create with provided values only
|
||||
return Item(
|
||||
item_id=item_id,
|
||||
name=item_grant.name or "Unknown Item",
|
||||
item_type=item_type,
|
||||
description=item_grant.description or "A simple item.",
|
||||
value=item_grant.value,
|
||||
is_tradeable=True,
|
||||
required_level=1,
|
||||
)
|
||||
|
||||
def _find_template(self, item_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Find a generic item template by name.
|
||||
|
||||
Uses case-insensitive partial matching.
|
||||
|
||||
Args:
|
||||
item_name: Name of the item to find
|
||||
|
||||
Returns:
|
||||
Template dict or None if not found
|
||||
"""
|
||||
name_lower = item_name.lower()
|
||||
|
||||
# Exact match first
|
||||
if name_lower in self._generic_templates:
|
||||
return self._generic_templates[name_lower]
|
||||
|
||||
# Partial match
|
||||
for template_name, template in self._generic_templates.items():
|
||||
if template_name in name_lower or name_lower in template_name:
|
||||
return template
|
||||
|
||||
return None
|
||||
|
||||
def validate_item_for_character(
|
||||
self,
|
||||
item: Item,
|
||||
character: Character
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate that a character can receive an item.
|
||||
|
||||
Checks:
|
||||
- Level requirements
|
||||
- Class restrictions
|
||||
|
||||
Args:
|
||||
item: The Item to validate
|
||||
character: The Character to receive the item
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Check level requirement
|
||||
if item.required_level > character.level:
|
||||
error_msg = (
|
||||
f"Item '{item.name}' requires level {item.required_level}, "
|
||||
f"but character is level {character.level}"
|
||||
)
|
||||
logger.warning(
|
||||
"Item validation failed: level requirement",
|
||||
item_name=item.name,
|
||||
required_level=item.required_level,
|
||||
character_level=character.level,
|
||||
character_name=character.name
|
||||
)
|
||||
return False, error_msg
|
||||
|
||||
# Check class restriction
|
||||
if item.required_class:
|
||||
character_class = character.player_class.class_id
|
||||
if item.required_class.lower() != character_class.lower():
|
||||
error_msg = (
|
||||
f"Item '{item.name}' requires class {item.required_class}, "
|
||||
f"but character is {character_class}"
|
||||
)
|
||||
logger.warning(
|
||||
"Item validation failed: class restriction",
|
||||
item_name=item.name,
|
||||
required_class=item.required_class,
|
||||
character_class=character_class,
|
||||
character_name=character.name
|
||||
)
|
||||
return False, error_msg
|
||||
|
||||
return True, None
|
||||
|
||||
def validate_and_resolve_item(
|
||||
self,
|
||||
item_grant: ItemGrant,
|
||||
character: Character
|
||||
) -> tuple[Optional[Item], Optional[str]]:
|
||||
"""
|
||||
Resolve an item grant and validate it for a character.
|
||||
|
||||
This is the main entry point for processing AI-granted items.
|
||||
|
||||
Args:
|
||||
item_grant: The ItemGrant from AI response
|
||||
character: The Character to receive the item
|
||||
|
||||
Returns:
|
||||
Tuple of (Item if valid else None, error_message if invalid else None)
|
||||
"""
|
||||
try:
|
||||
# Resolve the item
|
||||
item = self.resolve_item(item_grant)
|
||||
|
||||
# Validate for character
|
||||
is_valid, error_msg = self.validate_item_for_character(item, character)
|
||||
|
||||
if not is_valid:
|
||||
return None, error_msg
|
||||
|
||||
logger.info(
|
||||
"Item validated successfully",
|
||||
item_name=item.name,
|
||||
item_id=item.item_id,
|
||||
character_name=character.name
|
||||
)
|
||||
return item, None
|
||||
|
||||
except ItemValidationError as e:
|
||||
logger.warning(
|
||||
"Item resolution failed",
|
||||
error=e.message,
|
||||
reason=e.reason
|
||||
)
|
||||
return None, e.message
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_validator_instance: Optional[ItemValidator] = None
|
||||
|
||||
|
||||
def get_item_validator() -> ItemValidator:
|
||||
"""
|
||||
Get or create the global ItemValidator instance.
|
||||
|
||||
Returns:
|
||||
ItemValidator singleton instance
|
||||
"""
|
||||
global _validator_instance
|
||||
if _validator_instance is None:
|
||||
_validator_instance = ItemValidator()
|
||||
return _validator_instance
|
||||
326
api/app/services/location_loader.py
Normal file
326
api/app/services/location_loader.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
LocationLoader service for loading location definitions from YAML files.
|
||||
|
||||
This service reads location configuration files and converts them into Location
|
||||
dataclass instances, providing caching for performance. Locations are organized
|
||||
by region subdirectories.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.location import Location, Region
|
||||
from app.models.enums import LocationType
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class LocationLoader:
|
||||
"""
|
||||
Loads location definitions from YAML configuration files.
|
||||
|
||||
Locations are organized in region subdirectories:
|
||||
/app/data/locations/
|
||||
regions/
|
||||
crossville.yaml
|
||||
crossville/
|
||||
crossville_village.yaml
|
||||
crossville_tavern.yaml
|
||||
|
||||
This allows game designers to define world locations without touching code.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the location loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing location YAML files.
|
||||
Defaults to /app/data/locations/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/locations relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "locations")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._location_cache: Dict[str, Location] = {}
|
||||
self._region_cache: Dict[str, Region] = {}
|
||||
|
||||
logger.info("LocationLoader initialized", data_dir=str(self.data_dir))
|
||||
|
||||
def load_location(self, location_id: str) -> Optional[Location]:
|
||||
"""
|
||||
Load a single location by ID.
|
||||
|
||||
Searches all region subdirectories for the location file.
|
||||
|
||||
Args:
|
||||
location_id: Unique location identifier (e.g., "crossville_tavern")
|
||||
|
||||
Returns:
|
||||
Location instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if location_id in self._location_cache:
|
||||
logger.debug("Location loaded from cache", location_id=location_id)
|
||||
return self._location_cache[location_id]
|
||||
|
||||
# Search in region subdirectories
|
||||
if not self.data_dir.exists():
|
||||
logger.error("Location data directory does not exist", data_dir=str(self.data_dir))
|
||||
return None
|
||||
|
||||
for region_dir in self.data_dir.iterdir():
|
||||
# Skip non-directories and the regions folder
|
||||
if not region_dir.is_dir() or region_dir.name == "regions":
|
||||
continue
|
||||
|
||||
file_path = region_dir / f"{location_id}.yaml"
|
||||
if file_path.exists():
|
||||
return self._load_location_file(file_path)
|
||||
|
||||
logger.warning("Location not found", location_id=location_id)
|
||||
return None
|
||||
|
||||
def _load_location_file(self, file_path: Path) -> Optional[Location]:
|
||||
"""
|
||||
Load a location from a specific file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Location instance or None if loading fails
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
location = self._parse_location_data(data)
|
||||
self._location_cache[location.location_id] = location
|
||||
|
||||
logger.info("Location loaded successfully", location_id=location.location_id)
|
||||
return location
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load location", file=str(file_path), error=str(e))
|
||||
return None
|
||||
|
||||
def _parse_location_data(self, data: Dict) -> Location:
|
||||
"""
|
||||
Parse YAML data into a Location dataclass.
|
||||
|
||||
Args:
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
Location instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["location_id", "name", "region_id", "description"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Parse location type - default to town
|
||||
location_type_str = data.get("location_type", "town")
|
||||
try:
|
||||
location_type = LocationType(location_type_str)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid location type, defaulting to town",
|
||||
location_id=data["location_id"],
|
||||
invalid_type=location_type_str
|
||||
)
|
||||
location_type = LocationType.TOWN
|
||||
|
||||
return Location(
|
||||
location_id=data["location_id"],
|
||||
name=data["name"],
|
||||
location_type=location_type,
|
||||
region_id=data["region_id"],
|
||||
description=data["description"],
|
||||
lore=data.get("lore"),
|
||||
ambient_description=data.get("ambient_description"),
|
||||
available_quests=data.get("available_quests", []),
|
||||
npc_ids=data.get("npc_ids", []),
|
||||
discoverable_locations=data.get("discoverable_locations", []),
|
||||
is_starting_location=data.get("is_starting_location", False),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def load_all_locations(self) -> List[Location]:
|
||||
"""
|
||||
Load all locations from all region directories.
|
||||
|
||||
Returns:
|
||||
List of Location instances
|
||||
"""
|
||||
locations = []
|
||||
|
||||
if not self.data_dir.exists():
|
||||
logger.error("Location data directory does not exist", data_dir=str(self.data_dir))
|
||||
return locations
|
||||
|
||||
for region_dir in self.data_dir.iterdir():
|
||||
# Skip non-directories and the regions folder
|
||||
if not region_dir.is_dir() or region_dir.name == "regions":
|
||||
continue
|
||||
|
||||
for file_path in region_dir.glob("*.yaml"):
|
||||
location = self._load_location_file(file_path)
|
||||
if location:
|
||||
locations.append(location)
|
||||
|
||||
logger.info("All locations loaded", count=len(locations))
|
||||
return locations
|
||||
|
||||
def load_region(self, region_id: str) -> Optional[Region]:
|
||||
"""
|
||||
Load a region definition.
|
||||
|
||||
Args:
|
||||
region_id: Unique region identifier (e.g., "crossville")
|
||||
|
||||
Returns:
|
||||
Region instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if region_id in self._region_cache:
|
||||
logger.debug("Region loaded from cache", region_id=region_id)
|
||||
return self._region_cache[region_id]
|
||||
|
||||
file_path = self.data_dir / "regions" / f"{region_id}.yaml"
|
||||
|
||||
if not file_path.exists():
|
||||
logger.warning("Region file not found", region_id=region_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
region = Region.from_dict(data)
|
||||
self._region_cache[region_id] = region
|
||||
|
||||
logger.info("Region loaded successfully", region_id=region_id)
|
||||
return region
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load region", region_id=region_id, error=str(e))
|
||||
return None
|
||||
|
||||
def get_locations_in_region(self, region_id: str) -> List[Location]:
|
||||
"""
|
||||
Get all locations belonging to a specific region.
|
||||
|
||||
Args:
|
||||
region_id: Region identifier
|
||||
|
||||
Returns:
|
||||
List of Location instances in this region
|
||||
"""
|
||||
# Load all locations if cache is empty
|
||||
if not self._location_cache:
|
||||
self.load_all_locations()
|
||||
|
||||
return [
|
||||
loc for loc in self._location_cache.values()
|
||||
if loc.region_id == region_id
|
||||
]
|
||||
|
||||
def get_starting_locations(self) -> List[Location]:
|
||||
"""
|
||||
Get all locations that can be starting points.
|
||||
|
||||
Returns:
|
||||
List of Location instances marked as starting locations
|
||||
"""
|
||||
# Load all locations if cache is empty
|
||||
if not self._location_cache:
|
||||
self.load_all_locations()
|
||||
|
||||
return [
|
||||
loc for loc in self._location_cache.values()
|
||||
if loc.is_starting_location
|
||||
]
|
||||
|
||||
def get_location_by_type(self, location_type: LocationType) -> List[Location]:
|
||||
"""
|
||||
Get all locations of a specific type.
|
||||
|
||||
Args:
|
||||
location_type: Type to filter by
|
||||
|
||||
Returns:
|
||||
List of Location instances of this type
|
||||
"""
|
||||
# Load all locations if cache is empty
|
||||
if not self._location_cache:
|
||||
self.load_all_locations()
|
||||
|
||||
return [
|
||||
loc for loc in self._location_cache.values()
|
||||
if loc.location_type == location_type
|
||||
]
|
||||
|
||||
def get_all_location_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available location IDs.
|
||||
|
||||
Returns:
|
||||
List of location IDs
|
||||
"""
|
||||
# Load all locations if cache is empty
|
||||
if not self._location_cache:
|
||||
self.load_all_locations()
|
||||
|
||||
return list(self._location_cache.keys())
|
||||
|
||||
def reload_location(self, location_id: str) -> Optional[Location]:
|
||||
"""
|
||||
Force reload a location from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when location definitions change.
|
||||
|
||||
Args:
|
||||
location_id: Unique location identifier
|
||||
|
||||
Returns:
|
||||
Location instance or None if not found
|
||||
"""
|
||||
# Remove from cache if present
|
||||
if location_id in self._location_cache:
|
||||
del self._location_cache[location_id]
|
||||
|
||||
return self.load_location(location_id)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cached data. Useful for testing."""
|
||||
self._location_cache.clear()
|
||||
self._region_cache.clear()
|
||||
logger.info("Location cache cleared")
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_loader_instance: Optional[LocationLoader] = None
|
||||
|
||||
|
||||
def get_location_loader() -> LocationLoader:
|
||||
"""
|
||||
Get the global LocationLoader instance.
|
||||
|
||||
Returns:
|
||||
Singleton LocationLoader instance
|
||||
"""
|
||||
global _loader_instance
|
||||
if _loader_instance is None:
|
||||
_loader_instance = LocationLoader()
|
||||
return _loader_instance
|
||||
385
api/app/services/npc_loader.py
Normal file
385
api/app/services/npc_loader.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
NPCLoader service for loading NPC definitions from YAML files.
|
||||
|
||||
This service reads NPC configuration files and converts them into NPC
|
||||
dataclass instances, providing caching for performance. NPCs are organized
|
||||
by region subdirectories.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.npc import (
|
||||
NPC,
|
||||
NPCPersonality,
|
||||
NPCAppearance,
|
||||
NPCKnowledge,
|
||||
NPCKnowledgeCondition,
|
||||
NPCRelationship,
|
||||
NPCInventoryItem,
|
||||
NPCDialogueHooks,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class NPCLoader:
|
||||
"""
|
||||
Loads NPC definitions from YAML configuration files.
|
||||
|
||||
NPCs are organized in region subdirectories:
|
||||
/app/data/npcs/
|
||||
crossville/
|
||||
npc_grom_001.yaml
|
||||
npc_mira_001.yaml
|
||||
|
||||
This allows game designers to define NPCs without touching code.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the NPC loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing NPC YAML files.
|
||||
Defaults to /app/data/npcs/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/npcs relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "npcs")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._npc_cache: Dict[str, NPC] = {}
|
||||
self._location_npc_cache: Dict[str, List[str]] = {}
|
||||
|
||||
logger.info("NPCLoader initialized", data_dir=str(self.data_dir))
|
||||
|
||||
def load_npc(self, npc_id: str) -> Optional[NPC]:
|
||||
"""
|
||||
Load a single NPC by ID.
|
||||
|
||||
Searches all region subdirectories for the NPC file.
|
||||
|
||||
Args:
|
||||
npc_id: Unique NPC identifier (e.g., "npc_grom_001")
|
||||
|
||||
Returns:
|
||||
NPC instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if npc_id in self._npc_cache:
|
||||
logger.debug("NPC loaded from cache", npc_id=npc_id)
|
||||
return self._npc_cache[npc_id]
|
||||
|
||||
# Search in region subdirectories
|
||||
if not self.data_dir.exists():
|
||||
logger.error("NPC data directory does not exist", data_dir=str(self.data_dir))
|
||||
return None
|
||||
|
||||
for region_dir in self.data_dir.iterdir():
|
||||
if not region_dir.is_dir():
|
||||
continue
|
||||
|
||||
file_path = region_dir / f"{npc_id}.yaml"
|
||||
if file_path.exists():
|
||||
return self._load_npc_file(file_path)
|
||||
|
||||
logger.warning("NPC not found", npc_id=npc_id)
|
||||
return None
|
||||
|
||||
def _load_npc_file(self, file_path: Path) -> Optional[NPC]:
|
||||
"""
|
||||
Load an NPC from a specific file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
NPC instance or None if loading fails
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
npc = self._parse_npc_data(data)
|
||||
self._npc_cache[npc.npc_id] = npc
|
||||
|
||||
# Update location cache
|
||||
if npc.location_id not in self._location_npc_cache:
|
||||
self._location_npc_cache[npc.location_id] = []
|
||||
if npc.npc_id not in self._location_npc_cache[npc.location_id]:
|
||||
self._location_npc_cache[npc.location_id].append(npc.npc_id)
|
||||
|
||||
logger.info("NPC loaded successfully", npc_id=npc.npc_id)
|
||||
return npc
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load NPC", file=str(file_path), error=str(e))
|
||||
return None
|
||||
|
||||
def _parse_npc_data(self, data: Dict) -> NPC:
|
||||
"""
|
||||
Parse YAML data into an NPC dataclass.
|
||||
|
||||
Args:
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
NPC instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["npc_id", "name", "role", "location_id"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Parse personality
|
||||
personality_data = data.get("personality", {})
|
||||
personality = NPCPersonality(
|
||||
traits=personality_data.get("traits", []),
|
||||
speech_style=personality_data.get("speech_style", ""),
|
||||
quirks=personality_data.get("quirks", []),
|
||||
)
|
||||
|
||||
# Parse appearance
|
||||
appearance_data = data.get("appearance", {})
|
||||
if isinstance(appearance_data, str):
|
||||
appearance = NPCAppearance(brief=appearance_data)
|
||||
else:
|
||||
appearance = NPCAppearance(
|
||||
brief=appearance_data.get("brief", ""),
|
||||
detailed=appearance_data.get("detailed"),
|
||||
)
|
||||
|
||||
# Parse knowledge (optional)
|
||||
knowledge = None
|
||||
if data.get("knowledge"):
|
||||
knowledge_data = data["knowledge"]
|
||||
conditions = [
|
||||
NPCKnowledgeCondition(
|
||||
condition=c.get("condition", ""),
|
||||
reveals=c.get("reveals", ""),
|
||||
)
|
||||
for c in knowledge_data.get("will_share_if", [])
|
||||
]
|
||||
knowledge = NPCKnowledge(
|
||||
public=knowledge_data.get("public", []),
|
||||
secret=knowledge_data.get("secret", []),
|
||||
will_share_if=conditions,
|
||||
)
|
||||
|
||||
# Parse relationships
|
||||
relationships = [
|
||||
NPCRelationship(
|
||||
npc_id=r["npc_id"],
|
||||
attitude=r["attitude"],
|
||||
reason=r.get("reason"),
|
||||
)
|
||||
for r in data.get("relationships", [])
|
||||
]
|
||||
|
||||
# Parse inventory
|
||||
inventory = []
|
||||
for item_data in data.get("inventory_for_sale", []):
|
||||
# Handle shorthand format: { item: "ale", price: 2 }
|
||||
item_id = item_data.get("item_id") or item_data.get("item", "")
|
||||
inventory.append(NPCInventoryItem(
|
||||
item_id=item_id,
|
||||
price=item_data.get("price", 0),
|
||||
quantity=item_data.get("quantity"),
|
||||
))
|
||||
|
||||
# Parse dialogue hooks (optional)
|
||||
dialogue_hooks = None
|
||||
if data.get("dialogue_hooks"):
|
||||
hooks_data = data["dialogue_hooks"]
|
||||
dialogue_hooks = NPCDialogueHooks(
|
||||
greeting=hooks_data.get("greeting"),
|
||||
farewell=hooks_data.get("farewell"),
|
||||
busy=hooks_data.get("busy"),
|
||||
quest_complete=hooks_data.get("quest_complete"),
|
||||
)
|
||||
|
||||
return NPC(
|
||||
npc_id=data["npc_id"],
|
||||
name=data["name"],
|
||||
role=data["role"],
|
||||
location_id=data["location_id"],
|
||||
personality=personality,
|
||||
appearance=appearance,
|
||||
knowledge=knowledge,
|
||||
relationships=relationships,
|
||||
inventory_for_sale=inventory,
|
||||
dialogue_hooks=dialogue_hooks,
|
||||
quest_giver_for=data.get("quest_giver_for", []),
|
||||
reveals_locations=data.get("reveals_locations", []),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def load_all_npcs(self) -> List[NPC]:
|
||||
"""
|
||||
Load all NPCs from all region directories.
|
||||
|
||||
Returns:
|
||||
List of NPC instances
|
||||
"""
|
||||
npcs = []
|
||||
|
||||
if not self.data_dir.exists():
|
||||
logger.error("NPC data directory does not exist", data_dir=str(self.data_dir))
|
||||
return npcs
|
||||
|
||||
for region_dir in self.data_dir.iterdir():
|
||||
if not region_dir.is_dir():
|
||||
continue
|
||||
|
||||
for file_path in region_dir.glob("*.yaml"):
|
||||
npc = self._load_npc_file(file_path)
|
||||
if npc:
|
||||
npcs.append(npc)
|
||||
|
||||
logger.info("All NPCs loaded", count=len(npcs))
|
||||
return npcs
|
||||
|
||||
def get_npcs_at_location(self, location_id: str) -> List[NPC]:
|
||||
"""
|
||||
Get all NPCs at a specific location.
|
||||
|
||||
Args:
|
||||
location_id: Location identifier
|
||||
|
||||
Returns:
|
||||
List of NPC instances at this location
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
npc_ids = self._location_npc_cache.get(location_id, [])
|
||||
return [
|
||||
self._npc_cache[npc_id]
|
||||
for npc_id in npc_ids
|
||||
if npc_id in self._npc_cache
|
||||
]
|
||||
|
||||
def get_npc_ids_at_location(self, location_id: str) -> List[str]:
|
||||
"""
|
||||
Get NPC IDs at a specific location.
|
||||
|
||||
Args:
|
||||
location_id: Location identifier
|
||||
|
||||
Returns:
|
||||
List of NPC IDs at this location
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
return self._location_npc_cache.get(location_id, [])
|
||||
|
||||
def get_npcs_by_tag(self, tag: str) -> List[NPC]:
|
||||
"""
|
||||
Get all NPCs with a specific tag.
|
||||
|
||||
Args:
|
||||
tag: Tag to filter by (e.g., "merchant", "quest_giver")
|
||||
|
||||
Returns:
|
||||
List of NPC instances with this tag
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
return [
|
||||
npc for npc in self._npc_cache.values()
|
||||
if tag in npc.tags
|
||||
]
|
||||
|
||||
def get_quest_givers(self, quest_id: str) -> List[NPC]:
|
||||
"""
|
||||
Get all NPCs that can give a specific quest.
|
||||
|
||||
Args:
|
||||
quest_id: Quest identifier
|
||||
|
||||
Returns:
|
||||
List of NPC instances that give this quest
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
return [
|
||||
npc for npc in self._npc_cache.values()
|
||||
if quest_id in npc.quest_giver_for
|
||||
]
|
||||
|
||||
def get_all_npc_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available NPC IDs.
|
||||
|
||||
Returns:
|
||||
List of NPC IDs
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
return list(self._npc_cache.keys())
|
||||
|
||||
def reload_npc(self, npc_id: str) -> Optional[NPC]:
|
||||
"""
|
||||
Force reload an NPC from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when NPC definitions change.
|
||||
|
||||
Args:
|
||||
npc_id: Unique NPC identifier
|
||||
|
||||
Returns:
|
||||
NPC instance or None if not found
|
||||
"""
|
||||
# Remove from caches if present
|
||||
if npc_id in self._npc_cache:
|
||||
old_npc = self._npc_cache[npc_id]
|
||||
# Remove from location cache
|
||||
if old_npc.location_id in self._location_npc_cache:
|
||||
self._location_npc_cache[old_npc.location_id] = [
|
||||
n for n in self._location_npc_cache[old_npc.location_id]
|
||||
if n != npc_id
|
||||
]
|
||||
del self._npc_cache[npc_id]
|
||||
|
||||
return self.load_npc(npc_id)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cached data. Useful for testing."""
|
||||
self._npc_cache.clear()
|
||||
self._location_npc_cache.clear()
|
||||
logger.info("NPC cache cleared")
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_loader_instance: Optional[NPCLoader] = None
|
||||
|
||||
|
||||
def get_npc_loader() -> NPCLoader:
|
||||
"""
|
||||
Get the global NPCLoader instance.
|
||||
|
||||
Returns:
|
||||
Singleton NPCLoader instance
|
||||
"""
|
||||
global _loader_instance
|
||||
if _loader_instance is None:
|
||||
_loader_instance = NPCLoader()
|
||||
return _loader_instance
|
||||
236
api/app/services/origin_service.py
Normal file
236
api/app/services/origin_service.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
OriginService for loading character origin definitions from YAML files.
|
||||
|
||||
This service reads origin configuration and converts it into Origin
|
||||
dataclass instances, providing caching for performance.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.origins import Origin, StartingLocation, StartingBonus
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OriginService:
|
||||
"""
|
||||
Loads character origin definitions from YAML configuration.
|
||||
|
||||
Origins define character backstories, starting locations, and narrative
|
||||
hooks that the AI DM uses to create personalized gameplay experiences.
|
||||
All origin definitions are stored in /app/data/origins.yaml.
|
||||
"""
|
||||
|
||||
def __init__(self, data_file: Optional[str] = None):
|
||||
"""
|
||||
Initialize the origin service.
|
||||
|
||||
Args:
|
||||
data_file: Path to origins YAML file.
|
||||
Defaults to /app/data/origins.yaml
|
||||
"""
|
||||
if data_file is None:
|
||||
# Default to app/data/origins.yaml relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_file = str(app_dir / "data" / "origins.yaml")
|
||||
|
||||
self.data_file = Path(data_file)
|
||||
self._origins_cache: Dict[str, Origin] = {}
|
||||
self._all_origins_loaded = False
|
||||
|
||||
logger.info("OriginService initialized", data_file=str(self.data_file))
|
||||
|
||||
def load_origin(self, origin_id: str) -> Optional[Origin]:
|
||||
"""
|
||||
Load a single origin by ID.
|
||||
|
||||
Args:
|
||||
origin_id: Unique origin identifier (e.g., "soul_revenant")
|
||||
|
||||
Returns:
|
||||
Origin instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if origin_id in self._origins_cache:
|
||||
logger.debug("Origin loaded from cache", origin_id=origin_id)
|
||||
return self._origins_cache[origin_id]
|
||||
|
||||
# Load all origins if not already loaded
|
||||
if not self._all_origins_loaded:
|
||||
self._load_all_origins()
|
||||
|
||||
# Return from cache after loading
|
||||
origin = self._origins_cache.get(origin_id)
|
||||
if origin:
|
||||
logger.info("Origin loaded successfully", origin_id=origin_id)
|
||||
else:
|
||||
logger.warning("Origin not found", origin_id=origin_id)
|
||||
|
||||
return origin
|
||||
|
||||
def load_all_origins(self) -> List[Origin]:
|
||||
"""
|
||||
Load all origins from the data file.
|
||||
|
||||
Returns:
|
||||
List of Origin instances
|
||||
"""
|
||||
if self._all_origins_loaded and self._origins_cache:
|
||||
logger.debug("All origins loaded from cache")
|
||||
return list(self._origins_cache.values())
|
||||
|
||||
return self._load_all_origins()
|
||||
|
||||
def _load_all_origins(self) -> List[Origin]:
|
||||
"""
|
||||
Internal method to load all origins from YAML.
|
||||
|
||||
Returns:
|
||||
List of Origin instances
|
||||
"""
|
||||
if not self.data_file.exists():
|
||||
logger.error("Origins data file does not exist", data_file=str(self.data_file))
|
||||
return []
|
||||
|
||||
try:
|
||||
# Load YAML file
|
||||
with open(self.data_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
origins_data = data.get("origins", {})
|
||||
origins = []
|
||||
|
||||
# Parse each origin
|
||||
for origin_id, origin_data in origins_data.items():
|
||||
try:
|
||||
origin = self._parse_origin_data(origin_id, origin_data)
|
||||
self._origins_cache[origin_id] = origin
|
||||
origins.append(origin)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse origin", origin_id=origin_id, error=str(e))
|
||||
continue
|
||||
|
||||
self._all_origins_loaded = True
|
||||
logger.info("All origins loaded successfully", count=len(origins))
|
||||
return origins
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load origins file", error=str(e))
|
||||
return []
|
||||
|
||||
def get_origin_by_id(self, origin_id: str) -> Optional[Origin]:
|
||||
"""
|
||||
Get an origin by ID (alias for load_origin).
|
||||
|
||||
Args:
|
||||
origin_id: Unique origin identifier
|
||||
|
||||
Returns:
|
||||
Origin instance or None if not found
|
||||
"""
|
||||
return self.load_origin(origin_id)
|
||||
|
||||
def get_all_origin_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available origin IDs.
|
||||
|
||||
Returns:
|
||||
List of origin IDs (e.g., ["soul_revenant", "memory_thief"])
|
||||
"""
|
||||
if not self._all_origins_loaded:
|
||||
self._load_all_origins()
|
||||
|
||||
return list(self._origins_cache.keys())
|
||||
|
||||
def reload_origins(self) -> List[Origin]:
|
||||
"""
|
||||
Force reload all origins from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when origin definitions change.
|
||||
|
||||
Returns:
|
||||
List of Origin instances
|
||||
"""
|
||||
self.clear_cache()
|
||||
return self._load_all_origins()
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the origins cache. Useful for testing."""
|
||||
self._origins_cache.clear()
|
||||
self._all_origins_loaded = False
|
||||
logger.info("Origins cache cleared")
|
||||
|
||||
def _parse_origin_data(self, origin_id: str, data: Dict) -> Origin:
|
||||
"""
|
||||
Parse YAML data into an Origin dataclass.
|
||||
|
||||
Args:
|
||||
origin_id: The origin's unique identifier
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
Origin instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["name", "description", "starting_location"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field in origin '{origin_id}': {field}")
|
||||
|
||||
# Parse starting location
|
||||
location_data = data["starting_location"]
|
||||
starting_location = StartingLocation(
|
||||
id=location_data.get("id", ""),
|
||||
name=location_data.get("name", ""),
|
||||
region=location_data.get("region", ""),
|
||||
description=location_data.get("description", "")
|
||||
)
|
||||
|
||||
# Parse starting bonus (optional)
|
||||
starting_bonus = None
|
||||
if "starting_bonus" in data:
|
||||
bonus_data = data["starting_bonus"]
|
||||
starting_bonus = StartingBonus(
|
||||
trait=bonus_data.get("trait", ""),
|
||||
description=bonus_data.get("description", ""),
|
||||
effect=bonus_data.get("effect", "")
|
||||
)
|
||||
|
||||
# Parse narrative hooks (optional)
|
||||
narrative_hooks = data.get("narrative_hooks", [])
|
||||
|
||||
# Create Origin instance
|
||||
origin = Origin(
|
||||
id=data.get("id", origin_id), # Use provided ID or fall back to key
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
starting_location=starting_location,
|
||||
narrative_hooks=narrative_hooks,
|
||||
starting_bonus=starting_bonus
|
||||
)
|
||||
|
||||
return origin
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_service_instance: Optional[OriginService] = None
|
||||
|
||||
|
||||
def get_origin_service() -> OriginService:
|
||||
"""
|
||||
Get the global OriginService instance.
|
||||
|
||||
Returns:
|
||||
Singleton OriginService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = OriginService()
|
||||
return _service_instance
|
||||
373
api/app/services/outcome_service.py
Normal file
373
api/app/services/outcome_service.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Outcome determination service for Code of Conquest.
|
||||
|
||||
This service handles all code-determined game outcomes before they're passed
|
||||
to AI for narration. It uses the dice mechanics system to determine success/failure
|
||||
and selects appropriate rewards from loot tables.
|
||||
"""
|
||||
|
||||
import random
|
||||
import yaml
|
||||
import structlog
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from app.models.character import Character
|
||||
from app.game_logic.dice import (
|
||||
CheckResult, SkillType, Difficulty,
|
||||
skill_check, get_stat_for_skill, perception_check
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItemFound:
|
||||
"""
|
||||
Represents an item found during a search.
|
||||
|
||||
Uses template key from generic_items.yaml.
|
||||
"""
|
||||
template_key: str
|
||||
name: str
|
||||
description: str
|
||||
value: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"template_key": self.template_key,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"value": self.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchOutcome:
|
||||
"""
|
||||
Complete result of a search action.
|
||||
|
||||
Includes the dice check result and any items/gold found.
|
||||
"""
|
||||
check_result: CheckResult
|
||||
items_found: List[ItemFound]
|
||||
gold_found: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"check_result": self.check_result.to_dict(),
|
||||
"items_found": [item.to_dict() for item in self.items_found],
|
||||
"gold_found": self.gold_found,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillCheckOutcome:
|
||||
"""
|
||||
Result of a generic skill check.
|
||||
|
||||
Used for persuasion, lockpicking, stealth, etc.
|
||||
"""
|
||||
check_result: CheckResult
|
||||
context: Dict[str, Any] # Additional context for AI narration
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"check_result": self.check_result.to_dict(),
|
||||
"context": self.context,
|
||||
}
|
||||
|
||||
|
||||
class OutcomeService:
|
||||
"""
|
||||
Service for determining game action outcomes.
|
||||
|
||||
Handles all dice rolls and loot selection before passing to AI.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the outcome service with loot tables and item templates."""
|
||||
self._loot_tables: Dict[str, Any] = {}
|
||||
self._item_templates: Dict[str, Any] = {}
|
||||
self._load_data()
|
||||
|
||||
def _load_data(self) -> None:
|
||||
"""Load loot tables and item templates from YAML files."""
|
||||
data_dir = Path(__file__).parent.parent / "data"
|
||||
|
||||
# Load loot tables
|
||||
loot_path = data_dir / "loot_tables.yaml"
|
||||
if loot_path.exists():
|
||||
with open(loot_path, "r") as f:
|
||||
self._loot_tables = yaml.safe_load(f)
|
||||
logger.info("loaded_loot_tables", count=len(self._loot_tables))
|
||||
else:
|
||||
logger.warning("loot_tables_not_found", path=str(loot_path))
|
||||
|
||||
# Load generic item templates
|
||||
items_path = data_dir / "generic_items.yaml"
|
||||
if items_path.exists():
|
||||
with open(items_path, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
self._item_templates = data.get("templates", {})
|
||||
logger.info("loaded_item_templates", count=len(self._item_templates))
|
||||
else:
|
||||
logger.warning("item_templates_not_found", path=str(items_path))
|
||||
|
||||
def determine_search_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
location_type: str,
|
||||
dc: int = 12,
|
||||
bonus: int = 0
|
||||
) -> SearchOutcome:
|
||||
"""
|
||||
Determine the outcome of a search action.
|
||||
|
||||
Uses a perception check to determine success, then selects items
|
||||
from the appropriate loot table based on the roll margin.
|
||||
|
||||
Args:
|
||||
character: The character performing the search
|
||||
location_type: Type of location (forest, cave, town, etc.)
|
||||
dc: Difficulty class (default 12 = easy-medium)
|
||||
bonus: Additional bonus to the check
|
||||
|
||||
Returns:
|
||||
SearchOutcome with check result, items found, and gold found
|
||||
"""
|
||||
# Get character's effective wisdom for perception
|
||||
effective_stats = character.get_effective_stats()
|
||||
wisdom = effective_stats.wisdom
|
||||
|
||||
# Perform the perception check
|
||||
check_result = perception_check(wisdom, dc, bonus)
|
||||
|
||||
# Determine loot based on result
|
||||
items_found: List[ItemFound] = []
|
||||
gold_found: int = 0
|
||||
|
||||
if check_result.success:
|
||||
# Get loot table for this location (fall back to default)
|
||||
loot_table = self._loot_tables.get(
|
||||
location_type.lower(),
|
||||
self._loot_tables.get("default", {})
|
||||
)
|
||||
|
||||
# Select item rarity based on margin
|
||||
if check_result.margin >= 10:
|
||||
rarity = "rare"
|
||||
elif check_result.margin >= 5:
|
||||
rarity = "uncommon"
|
||||
else:
|
||||
rarity = "common"
|
||||
|
||||
# Get items for this rarity
|
||||
item_keys = loot_table.get(rarity, [])
|
||||
if item_keys:
|
||||
# Select 1-2 items based on margin
|
||||
num_items = 1 if check_result.margin < 8 else 2
|
||||
selected_keys = random.sample(
|
||||
item_keys,
|
||||
min(num_items, len(item_keys))
|
||||
)
|
||||
|
||||
for key in selected_keys:
|
||||
template = self._item_templates.get(key)
|
||||
if template:
|
||||
items_found.append(ItemFound(
|
||||
template_key=key,
|
||||
name=template.get("name", key.title()),
|
||||
description=template.get("description", ""),
|
||||
value=template.get("value", 1),
|
||||
))
|
||||
|
||||
# Calculate gold found
|
||||
gold_config = loot_table.get("gold", {})
|
||||
if gold_config:
|
||||
min_gold = gold_config.get("min", 0)
|
||||
max_gold = gold_config.get("max", 10)
|
||||
bonus_per_margin = gold_config.get("bonus_per_margin", 0)
|
||||
|
||||
base_gold = random.randint(min_gold, max_gold)
|
||||
margin_bonus = check_result.margin * bonus_per_margin
|
||||
gold_found = base_gold + margin_bonus
|
||||
|
||||
logger.info(
|
||||
"search_outcome_determined",
|
||||
character_id=character.character_id,
|
||||
location_type=location_type,
|
||||
dc=dc,
|
||||
success=check_result.success,
|
||||
margin=check_result.margin,
|
||||
items_count=len(items_found),
|
||||
gold_found=gold_found
|
||||
)
|
||||
|
||||
return SearchOutcome(
|
||||
check_result=check_result,
|
||||
items_found=items_found,
|
||||
gold_found=gold_found
|
||||
)
|
||||
|
||||
def determine_skill_check_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
skill_type: SkillType,
|
||||
dc: int,
|
||||
bonus: int = 0,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> SkillCheckOutcome:
|
||||
"""
|
||||
Determine the outcome of a generic skill check.
|
||||
|
||||
Args:
|
||||
character: The character performing the check
|
||||
skill_type: The type of skill check (PERSUASION, STEALTH, etc.)
|
||||
dc: Difficulty class to beat
|
||||
bonus: Additional bonus to the check
|
||||
context: Optional context for AI narration (e.g., NPC name, door type)
|
||||
|
||||
Returns:
|
||||
SkillCheckOutcome with check result and context
|
||||
"""
|
||||
# Get the appropriate stat for this skill
|
||||
stat_name = get_stat_for_skill(skill_type)
|
||||
effective_stats = character.get_effective_stats()
|
||||
stat_value = getattr(effective_stats, stat_name, 10)
|
||||
|
||||
# Perform the check
|
||||
check_result = skill_check(stat_value, dc, skill_type, bonus)
|
||||
|
||||
# Build outcome context
|
||||
outcome_context = context or {}
|
||||
outcome_context["skill_used"] = skill_type.name.lower()
|
||||
outcome_context["stat_used"] = stat_name
|
||||
|
||||
logger.info(
|
||||
"skill_check_outcome_determined",
|
||||
character_id=character.character_id,
|
||||
skill=skill_type.name,
|
||||
stat=stat_name,
|
||||
dc=dc,
|
||||
success=check_result.success,
|
||||
margin=check_result.margin
|
||||
)
|
||||
|
||||
return SkillCheckOutcome(
|
||||
check_result=check_result,
|
||||
context=outcome_context
|
||||
)
|
||||
|
||||
def determine_persuasion_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
dc: int,
|
||||
npc_name: Optional[str] = None,
|
||||
bonus: int = 0
|
||||
) -> SkillCheckOutcome:
|
||||
"""
|
||||
Convenience method for persuasion checks.
|
||||
|
||||
Args:
|
||||
character: The character attempting persuasion
|
||||
dc: Difficulty class based on NPC disposition
|
||||
npc_name: Name of the NPC being persuaded
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
SkillCheckOutcome
|
||||
"""
|
||||
context = {"npc_name": npc_name} if npc_name else {}
|
||||
return self.determine_skill_check_outcome(
|
||||
character,
|
||||
SkillType.PERSUASION,
|
||||
dc,
|
||||
bonus,
|
||||
context
|
||||
)
|
||||
|
||||
def determine_stealth_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
dc: int,
|
||||
situation: Optional[str] = None,
|
||||
bonus: int = 0
|
||||
) -> SkillCheckOutcome:
|
||||
"""
|
||||
Convenience method for stealth checks.
|
||||
|
||||
Args:
|
||||
character: The character attempting stealth
|
||||
dc: Difficulty class based on environment/observers
|
||||
situation: Description of what they're sneaking past
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
SkillCheckOutcome
|
||||
"""
|
||||
context = {"situation": situation} if situation else {}
|
||||
return self.determine_skill_check_outcome(
|
||||
character,
|
||||
SkillType.STEALTH,
|
||||
dc,
|
||||
bonus,
|
||||
context
|
||||
)
|
||||
|
||||
def determine_lockpicking_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
dc: int,
|
||||
lock_description: Optional[str] = None,
|
||||
bonus: int = 0
|
||||
) -> SkillCheckOutcome:
|
||||
"""
|
||||
Convenience method for lockpicking checks.
|
||||
|
||||
Args:
|
||||
character: The character attempting to pick the lock
|
||||
dc: Difficulty class based on lock quality
|
||||
lock_description: Description of the lock/door
|
||||
bonus: Additional bonus (e.g., from thieves' tools)
|
||||
|
||||
Returns:
|
||||
SkillCheckOutcome
|
||||
"""
|
||||
context = {"lock_description": lock_description} if lock_description else {}
|
||||
return self.determine_skill_check_outcome(
|
||||
character,
|
||||
SkillType.LOCKPICKING,
|
||||
dc,
|
||||
bonus,
|
||||
context
|
||||
)
|
||||
|
||||
def get_dc_for_difficulty(self, difficulty: str) -> int:
|
||||
"""
|
||||
Get the DC value for a named difficulty.
|
||||
|
||||
Args:
|
||||
difficulty: Difficulty name (trivial, easy, medium, hard, very_hard)
|
||||
|
||||
Returns:
|
||||
DC value
|
||||
"""
|
||||
difficulty_map = {
|
||||
"trivial": Difficulty.TRIVIAL.value,
|
||||
"easy": Difficulty.EASY.value,
|
||||
"medium": Difficulty.MEDIUM.value,
|
||||
"hard": Difficulty.HARD.value,
|
||||
"very_hard": Difficulty.VERY_HARD.value,
|
||||
"nearly_impossible": Difficulty.NEARLY_IMPOSSIBLE.value,
|
||||
}
|
||||
return difficulty_map.get(difficulty.lower(), Difficulty.MEDIUM.value)
|
||||
|
||||
|
||||
# Global instance for use in API endpoints
|
||||
outcome_service = OutcomeService()
|
||||
602
api/app/services/rate_limiter_service.py
Normal file
602
api/app/services/rate_limiter_service.py
Normal file
@@ -0,0 +1,602 @@
|
||||
"""
|
||||
Rate Limiter Service
|
||||
|
||||
This module implements tier-based rate limiting for AI requests using Redis
|
||||
for distributed counting. Each user tier has a different daily limit for
|
||||
AI-generated turns.
|
||||
|
||||
Usage:
|
||||
from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
# Initialize service
|
||||
rate_limiter = RateLimiterService()
|
||||
|
||||
# Check and increment usage
|
||||
try:
|
||||
rate_limiter.check_rate_limit("user_123", UserTier.FREE)
|
||||
rate_limiter.increment_usage("user_123")
|
||||
except RateLimitExceeded as e:
|
||||
print(f"Rate limit exceeded: {e}")
|
||||
|
||||
# Get remaining turns
|
||||
remaining = rate_limiter.get_remaining_turns("user_123", UserTier.FREE)
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.services.redis_service import RedisService, RedisServiceError
|
||||
from app.ai.model_selector import UserTier
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class RateLimitExceeded(Exception):
|
||||
"""
|
||||
Raised when a user has exceeded their daily rate limit.
|
||||
|
||||
Attributes:
|
||||
user_id: The user who exceeded the limit
|
||||
user_tier: The user's subscription tier
|
||||
limit: The daily limit for their tier
|
||||
current_usage: The current usage count
|
||||
reset_time: UTC timestamp when the limit resets
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
user_tier: UserTier,
|
||||
limit: int,
|
||||
current_usage: int,
|
||||
reset_time: datetime
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.user_tier = user_tier
|
||||
self.limit = limit
|
||||
self.current_usage = current_usage
|
||||
self.reset_time = reset_time
|
||||
|
||||
message = (
|
||||
f"Rate limit exceeded for user {user_id} ({user_tier.value} tier). "
|
||||
f"Used {current_usage}/{limit} turns. Resets at {reset_time.isoformat()}"
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RateLimiterService:
|
||||
"""
|
||||
Service for managing tier-based rate limiting.
|
||||
|
||||
This service uses Redis to track daily AI usage per user and enforces
|
||||
limits based on subscription tier. Counters reset daily at midnight UTC.
|
||||
|
||||
Tier Limits:
|
||||
- Free: 20 turns/day
|
||||
- Basic: 50 turns/day
|
||||
- Premium: 100 turns/day
|
||||
- Elite: 200 turns/day
|
||||
|
||||
Attributes:
|
||||
redis: RedisService instance for counter storage
|
||||
tier_limits: Mapping of tier to daily turn limit
|
||||
"""
|
||||
|
||||
# Daily turn limits per tier
|
||||
TIER_LIMITS = {
|
||||
UserTier.FREE: 20,
|
||||
UserTier.BASIC: 50,
|
||||
UserTier.PREMIUM: 100,
|
||||
UserTier.ELITE: 200,
|
||||
}
|
||||
|
||||
# Daily DM question limits per tier
|
||||
DM_QUESTION_LIMITS = {
|
||||
UserTier.FREE: 10,
|
||||
UserTier.BASIC: 20,
|
||||
UserTier.PREMIUM: 50,
|
||||
UserTier.ELITE: -1, # -1 means unlimited
|
||||
}
|
||||
|
||||
# Redis key prefix for rate limit counters
|
||||
KEY_PREFIX = "rate_limit:daily:"
|
||||
DM_QUESTION_PREFIX = "rate_limit:dm_questions:"
|
||||
|
||||
def __init__(self, redis_service: Optional[RedisService] = None):
|
||||
"""
|
||||
Initialize the rate limiter service.
|
||||
|
||||
Args:
|
||||
redis_service: Optional RedisService instance. If not provided,
|
||||
a new instance will be created.
|
||||
"""
|
||||
self.redis = redis_service or RedisService()
|
||||
|
||||
logger.info(
|
||||
"RateLimiterService initialized",
|
||||
tier_limits=self.TIER_LIMITS
|
||||
)
|
||||
|
||||
def _get_daily_key(self, user_id: str, day: Optional[date] = None) -> str:
|
||||
"""
|
||||
Generate the Redis key for a user's daily counter.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
day: The date (defaults to today UTC)
|
||||
|
||||
Returns:
|
||||
Redis key in format "rate_limit:daily:user_id:YYYY-MM-DD"
|
||||
"""
|
||||
if day is None:
|
||||
day = datetime.now(timezone.utc).date()
|
||||
|
||||
return f"{self.KEY_PREFIX}{user_id}:{day.isoformat()}"
|
||||
|
||||
def _get_seconds_until_midnight_utc(self) -> int:
|
||||
"""
|
||||
Calculate seconds remaining until midnight UTC.
|
||||
|
||||
Returns:
|
||||
Number of seconds until the next UTC midnight
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
tomorrow = datetime(
|
||||
now.year, now.month, now.day,
|
||||
tzinfo=timezone.utc
|
||||
) + timedelta(days=1)
|
||||
|
||||
return int((tomorrow - now).total_seconds())
|
||||
|
||||
def _get_reset_time(self) -> datetime:
|
||||
"""
|
||||
Get the UTC datetime when the rate limit resets.
|
||||
|
||||
Returns:
|
||||
Datetime of next midnight UTC
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
return datetime(
|
||||
now.year, now.month, now.day,
|
||||
tzinfo=timezone.utc
|
||||
) + timedelta(days=1)
|
||||
|
||||
def get_limit_for_tier(self, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the daily turn limit for a specific tier.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Daily turn limit for the tier
|
||||
"""
|
||||
return self.TIER_LIMITS.get(user_tier, self.TIER_LIMITS[UserTier.FREE])
|
||||
|
||||
def get_current_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
Get the current daily usage count for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
|
||||
Returns:
|
||||
Current usage count (0 if no usage today)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_daily_key(user_id)
|
||||
|
||||
try:
|
||||
value = self.redis.get(key)
|
||||
usage = int(value) if value else 0
|
||||
|
||||
logger.debug(
|
||||
"Retrieved current usage",
|
||||
user_id=user_id,
|
||||
usage=usage
|
||||
)
|
||||
|
||||
return usage
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(
|
||||
"Invalid usage value in Redis",
|
||||
user_id=user_id,
|
||||
error=str(e)
|
||||
)
|
||||
return 0
|
||||
|
||||
def check_rate_limit(self, user_id: str, user_tier: UserTier) -> None:
|
||||
"""
|
||||
Check if a user has exceeded their daily rate limit.
|
||||
|
||||
This method checks the current usage against the tier limit and
|
||||
raises an exception if the limit has been reached.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Raises:
|
||||
RateLimitExceeded: If the user has reached their daily limit
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
|
||||
if current_usage >= limit:
|
||||
reset_time = self._get_reset_time()
|
||||
|
||||
logger.warning(
|
||||
"Rate limit exceeded",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit,
|
||||
reset_time=reset_time.isoformat()
|
||||
)
|
||||
|
||||
raise RateLimitExceeded(
|
||||
user_id=user_id,
|
||||
user_tier=user_tier,
|
||||
limit=limit,
|
||||
current_usage=current_usage,
|
||||
reset_time=reset_time
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Rate limit check passed",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
def increment_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
Increment the daily usage counter for a user.
|
||||
|
||||
This method should be called after successfully processing an AI request.
|
||||
The counter will automatically expire at midnight UTC.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to increment
|
||||
|
||||
Returns:
|
||||
The new usage count after incrementing
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_daily_key(user_id)
|
||||
|
||||
# Increment the counter
|
||||
new_count = self.redis.incr(key)
|
||||
|
||||
# Set expiration if this is the first increment (new_count == 1)
|
||||
# This ensures the key expires at midnight UTC
|
||||
if new_count == 1:
|
||||
ttl = self._get_seconds_until_midnight_utc()
|
||||
self.redis.expire(key, ttl)
|
||||
|
||||
logger.debug(
|
||||
"Set expiration on new rate limit key",
|
||||
user_id=user_id,
|
||||
ttl=ttl
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Incremented usage counter",
|
||||
user_id=user_id,
|
||||
new_count=new_count
|
||||
)
|
||||
|
||||
return new_count
|
||||
|
||||
def get_remaining_turns(self, user_id: str, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the number of remaining turns for a user today.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Number of turns remaining (0 if limit reached)
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
|
||||
remaining = max(0, limit - current_usage)
|
||||
|
||||
logger.debug(
|
||||
"Calculated remaining turns",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit,
|
||||
remaining=remaining
|
||||
)
|
||||
|
||||
return remaining
|
||||
|
||||
def get_usage_info(self, user_id: str, user_tier: UserTier) -> dict:
|
||||
"""
|
||||
Get comprehensive usage information for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Dictionary with usage info:
|
||||
- user_id: User identifier
|
||||
- user_tier: Subscription tier
|
||||
- current_usage: Current daily usage
|
||||
- daily_limit: Daily limit for tier
|
||||
- remaining: Remaining turns
|
||||
- reset_time: ISO format UTC reset time
|
||||
- is_limited: Whether limit has been reached
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
remaining = max(0, limit - current_usage)
|
||||
reset_time = self._get_reset_time()
|
||||
|
||||
info = {
|
||||
"user_id": user_id,
|
||||
"user_tier": user_tier.value,
|
||||
"current_usage": current_usage,
|
||||
"daily_limit": limit,
|
||||
"remaining": remaining,
|
||||
"reset_time": reset_time.isoformat(),
|
||||
"is_limited": current_usage >= limit
|
||||
}
|
||||
|
||||
logger.debug("Retrieved usage info", **info)
|
||||
|
||||
return info
|
||||
|
||||
def reset_usage(self, user_id: str) -> bool:
|
||||
"""
|
||||
Reset the daily usage counter for a user.
|
||||
|
||||
This is primarily for admin/testing purposes.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to reset
|
||||
|
||||
Returns:
|
||||
True if the counter was deleted, False if it didn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_daily_key(user_id)
|
||||
deleted = self.redis.delete(key)
|
||||
|
||||
logger.info(
|
||||
"Reset usage counter",
|
||||
user_id=user_id,
|
||||
deleted=deleted > 0
|
||||
)
|
||||
|
||||
return deleted > 0
|
||||
|
||||
# ===== DM QUESTION RATE LIMITING =====
|
||||
|
||||
def _get_dm_question_key(self, user_id: str, day: Optional[date] = None) -> str:
|
||||
"""
|
||||
Generate the Redis key for a user's daily DM question counter.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
day: The date (defaults to today UTC)
|
||||
|
||||
Returns:
|
||||
Redis key in format "rate_limit:dm_questions:user_id:YYYY-MM-DD"
|
||||
"""
|
||||
if day is None:
|
||||
day = datetime.now(timezone.utc).date()
|
||||
|
||||
return f"{self.DM_QUESTION_PREFIX}{user_id}:{day.isoformat()}"
|
||||
|
||||
def get_dm_question_limit_for_tier(self, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the daily DM question limit for a specific tier.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Daily DM question limit for the tier (-1 for unlimited)
|
||||
"""
|
||||
return self.DM_QUESTION_LIMITS.get(user_tier, self.DM_QUESTION_LIMITS[UserTier.FREE])
|
||||
|
||||
def get_current_dm_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
Get the current daily DM question usage count for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
|
||||
Returns:
|
||||
Current DM question usage count (0 if no usage today)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_dm_question_key(user_id)
|
||||
|
||||
try:
|
||||
value = self.redis.get(key)
|
||||
usage = int(value) if value else 0
|
||||
|
||||
logger.debug(
|
||||
"Retrieved current DM question usage",
|
||||
user_id=user_id,
|
||||
usage=usage
|
||||
)
|
||||
|
||||
return usage
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(
|
||||
"Invalid DM question usage value in Redis",
|
||||
user_id=user_id,
|
||||
error=str(e)
|
||||
)
|
||||
return 0
|
||||
|
||||
def check_dm_question_limit(self, user_id: str, user_tier: UserTier) -> None:
|
||||
"""
|
||||
Check if a user has exceeded their daily DM question limit.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Raises:
|
||||
RateLimitExceeded: If the user has reached their daily DM question limit
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
limit = self.get_dm_question_limit_for_tier(user_tier)
|
||||
|
||||
# -1 means unlimited
|
||||
if limit == -1:
|
||||
logger.debug(
|
||||
"DM question limit check passed (unlimited)",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value
|
||||
)
|
||||
return
|
||||
|
||||
current_usage = self.get_current_dm_usage(user_id)
|
||||
|
||||
if current_usage >= limit:
|
||||
reset_time = self._get_reset_time()
|
||||
|
||||
logger.warning(
|
||||
"DM question limit exceeded",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit,
|
||||
reset_time=reset_time.isoformat()
|
||||
)
|
||||
|
||||
raise RateLimitExceeded(
|
||||
user_id=user_id,
|
||||
user_tier=user_tier,
|
||||
limit=limit,
|
||||
current_usage=current_usage,
|
||||
reset_time=reset_time
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"DM question limit check passed",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
def increment_dm_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
Increment the daily DM question counter for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to increment
|
||||
|
||||
Returns:
|
||||
The new DM question usage count after incrementing
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_dm_question_key(user_id)
|
||||
|
||||
# Increment the counter
|
||||
new_count = self.redis.incr(key)
|
||||
|
||||
# Set expiration if this is the first increment
|
||||
if new_count == 1:
|
||||
ttl = self._get_seconds_until_midnight_utc()
|
||||
self.redis.expire(key, ttl)
|
||||
|
||||
logger.debug(
|
||||
"Set expiration on new DM question key",
|
||||
user_id=user_id,
|
||||
ttl=ttl
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Incremented DM question counter",
|
||||
user_id=user_id,
|
||||
new_count=new_count
|
||||
)
|
||||
|
||||
return new_count
|
||||
|
||||
def get_remaining_dm_questions(self, user_id: str, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the number of remaining DM questions for a user today.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Number of DM questions remaining (-1 if unlimited, 0 if limit reached)
|
||||
"""
|
||||
limit = self.get_dm_question_limit_for_tier(user_tier)
|
||||
|
||||
# -1 means unlimited
|
||||
if limit == -1:
|
||||
return -1
|
||||
|
||||
current_usage = self.get_current_dm_usage(user_id)
|
||||
remaining = max(0, limit - current_usage)
|
||||
|
||||
logger.debug(
|
||||
"Calculated remaining DM questions",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit,
|
||||
remaining=remaining
|
||||
)
|
||||
|
||||
return remaining
|
||||
|
||||
def reset_dm_usage(self, user_id: str) -> bool:
|
||||
"""
|
||||
Reset the daily DM question counter for a user.
|
||||
|
||||
This is primarily for admin/testing purposes.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to reset
|
||||
|
||||
Returns:
|
||||
True if the counter was deleted, False if it didn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_dm_question_key(user_id)
|
||||
deleted = self.redis.delete(key)
|
||||
|
||||
logger.info(
|
||||
"Reset DM question counter",
|
||||
user_id=user_id,
|
||||
deleted=deleted > 0
|
||||
)
|
||||
|
||||
return deleted > 0
|
||||
505
api/app/services/redis_service.py
Normal file
505
api/app/services/redis_service.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
Redis Service Wrapper
|
||||
|
||||
This module provides a wrapper around the redis-py client for handling caching,
|
||||
job queue data, and temporary storage. It provides connection pooling, automatic
|
||||
reconnection, and a clean interface for common Redis operations.
|
||||
|
||||
Usage:
|
||||
from app.services.redis_service import RedisService
|
||||
|
||||
# Initialize service
|
||||
redis = RedisService()
|
||||
|
||||
# Basic operations
|
||||
redis.set("key", "value", ttl=3600) # Set with 1 hour TTL
|
||||
value = redis.get("key")
|
||||
redis.delete("key")
|
||||
|
||||
# Health check
|
||||
if redis.health_check():
|
||||
print("Redis is healthy")
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from typing import Optional, Any, Union
|
||||
|
||||
import redis
|
||||
from redis.exceptions import RedisError, ConnectionError as RedisConnectionError
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class RedisServiceError(Exception):
|
||||
"""Base exception for Redis service errors."""
|
||||
pass
|
||||
|
||||
|
||||
class RedisConnectionFailed(RedisServiceError):
|
||||
"""Raised when Redis connection cannot be established."""
|
||||
pass
|
||||
|
||||
|
||||
class RedisService:
|
||||
"""
|
||||
Service class for interacting with Redis.
|
||||
|
||||
This class provides:
|
||||
- Connection pooling for efficient connection management
|
||||
- Basic operations: get, set, delete, exists
|
||||
- TTL support for caching
|
||||
- Health check for monitoring
|
||||
- Automatic JSON serialization for complex objects
|
||||
|
||||
Attributes:
|
||||
pool: Redis connection pool
|
||||
client: Redis client instance
|
||||
"""
|
||||
|
||||
def __init__(self, redis_url: Optional[str] = None):
|
||||
"""
|
||||
Initialize the Redis service.
|
||||
|
||||
Reads configuration from environment variables if not provided:
|
||||
- REDIS_URL: Full Redis URL (e.g., redis://localhost:6379/0)
|
||||
|
||||
Args:
|
||||
redis_url: Optional Redis URL to override environment variable
|
||||
|
||||
Raises:
|
||||
RedisConnectionFailed: If connection to Redis fails
|
||||
"""
|
||||
self.redis_url = redis_url or os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
if not self.redis_url:
|
||||
logger.error("Missing Redis URL configuration")
|
||||
raise ValueError("Redis URL not configured. Set REDIS_URL environment variable.")
|
||||
|
||||
try:
|
||||
# Create connection pool for efficient connection management
|
||||
# Connection pooling allows multiple operations to share connections
|
||||
# and automatically manages connection lifecycle
|
||||
self.pool = redis.ConnectionPool.from_url(
|
||||
self.redis_url,
|
||||
max_connections=10,
|
||||
decode_responses=True, # Return strings instead of bytes
|
||||
socket_connect_timeout=5, # Connection timeout in seconds
|
||||
socket_timeout=5, # Operation timeout in seconds
|
||||
retry_on_timeout=True, # Retry on timeout
|
||||
)
|
||||
|
||||
# Create client using the connection pool
|
||||
self.client = redis.Redis(connection_pool=self.pool)
|
||||
|
||||
# Test connection
|
||||
self.client.ping()
|
||||
|
||||
logger.info("Redis service initialized", redis_url=self._sanitize_url(self.redis_url))
|
||||
|
||||
except RedisConnectionError as e:
|
||||
logger.error("Failed to connect to Redis", redis_url=self._sanitize_url(self.redis_url), error=str(e))
|
||||
raise RedisConnectionFailed(f"Could not connect to Redis at {self._sanitize_url(self.redis_url)}: {e}")
|
||||
except RedisError as e:
|
||||
logger.error("Redis initialization error", error=str(e))
|
||||
raise RedisServiceError(f"Redis initialization failed: {e}")
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get a value from Redis by key.
|
||||
|
||||
Args:
|
||||
key: The key to retrieve
|
||||
|
||||
Returns:
|
||||
The value as string if found, None if key doesn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
value = self.client.get(key)
|
||||
|
||||
if value is not None:
|
||||
logger.debug("Redis GET", key=key, found=True)
|
||||
else:
|
||||
logger.debug("Redis GET", key=key, found=False)
|
||||
|
||||
return value
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis GET failed", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to get key '{key}': {e}")
|
||||
|
||||
def get_json(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get a value from Redis and deserialize it from JSON.
|
||||
|
||||
Args:
|
||||
key: The key to retrieve
|
||||
|
||||
Returns:
|
||||
The deserialized value if found, None if key doesn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails or JSON is invalid
|
||||
"""
|
||||
value = self.get(key)
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to decode JSON from Redis", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to decode JSON for key '{key}': {e}")
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
ttl: Optional[int] = None,
|
||||
nx: bool = False,
|
||||
xx: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Set a value in Redis.
|
||||
|
||||
Args:
|
||||
key: The key to set
|
||||
value: The value to store (must be string)
|
||||
ttl: Time to live in seconds (None for no expiration)
|
||||
nx: Only set if key does not exist (for locking)
|
||||
xx: Only set if key already exists
|
||||
|
||||
Returns:
|
||||
True if the key was set, False if not set (due to nx/xx conditions)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
result = self.client.set(
|
||||
key,
|
||||
value,
|
||||
ex=ttl, # Expiration in seconds
|
||||
nx=nx, # Only set if not exists
|
||||
xx=xx # Only set if exists
|
||||
)
|
||||
|
||||
# set() returns True if set, None if not set due to nx/xx
|
||||
success = result is True or result == 1
|
||||
|
||||
logger.debug("Redis SET", key=key, ttl=ttl, nx=nx, xx=xx, success=success)
|
||||
|
||||
return success
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis SET failed", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to set key '{key}': {e}")
|
||||
|
||||
def set_json(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None,
|
||||
nx: bool = False,
|
||||
xx: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Serialize a value to JSON and store it in Redis.
|
||||
|
||||
Args:
|
||||
key: The key to set
|
||||
value: The value to serialize and store (must be JSON-serializable)
|
||||
ttl: Time to live in seconds (None for no expiration)
|
||||
nx: Only set if key does not exist
|
||||
xx: Only set if key already exists
|
||||
|
||||
Returns:
|
||||
True if the key was set, False if not set (due to nx/xx conditions)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails or value is not JSON-serializable
|
||||
"""
|
||||
try:
|
||||
json_value = json.dumps(value)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error("Failed to serialize value to JSON", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to serialize value for key '{key}': {e}")
|
||||
|
||||
return self.set(key, json_value, ttl=ttl, nx=nx, xx=xx)
|
||||
|
||||
def delete(self, *keys: str) -> int:
|
||||
"""
|
||||
Delete one or more keys from Redis.
|
||||
|
||||
Args:
|
||||
*keys: One or more keys to delete
|
||||
|
||||
Returns:
|
||||
The number of keys that were deleted
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
if not keys:
|
||||
return 0
|
||||
|
||||
try:
|
||||
deleted_count = self.client.delete(*keys)
|
||||
|
||||
logger.debug("Redis DELETE", keys=keys, deleted_count=deleted_count)
|
||||
|
||||
return deleted_count
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis DELETE failed", keys=keys, error=str(e))
|
||||
raise RedisServiceError(f"Failed to delete keys {keys}: {e}")
|
||||
|
||||
def exists(self, *keys: str) -> int:
|
||||
"""
|
||||
Check if one or more keys exist in Redis.
|
||||
|
||||
Args:
|
||||
*keys: One or more keys to check
|
||||
|
||||
Returns:
|
||||
The number of keys that exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
if not keys:
|
||||
return 0
|
||||
|
||||
try:
|
||||
exists_count = self.client.exists(*keys)
|
||||
|
||||
logger.debug("Redis EXISTS", keys=keys, exists_count=exists_count)
|
||||
|
||||
return exists_count
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis EXISTS failed", keys=keys, error=str(e))
|
||||
raise RedisServiceError(f"Failed to check existence of keys {keys}: {e}")
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""
|
||||
Set a TTL (time to live) on an existing key.
|
||||
|
||||
Args:
|
||||
key: The key to set expiration on
|
||||
ttl: Time to live in seconds
|
||||
|
||||
Returns:
|
||||
True if the timeout was set, False if key doesn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
result = self.client.expire(key, ttl)
|
||||
|
||||
logger.debug("Redis EXPIRE", key=key, ttl=ttl, success=result)
|
||||
|
||||
return result
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis EXPIRE failed", key=key, ttl=ttl, error=str(e))
|
||||
raise RedisServiceError(f"Failed to set expiration for key '{key}': {e}")
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
"""
|
||||
Get the remaining TTL (time to live) for a key.
|
||||
|
||||
Args:
|
||||
key: The key to check
|
||||
|
||||
Returns:
|
||||
TTL in seconds, -1 if key exists but has no expiry, -2 if key doesn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
remaining = self.client.ttl(key)
|
||||
|
||||
logger.debug("Redis TTL", key=key, remaining=remaining)
|
||||
|
||||
return remaining
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis TTL failed", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to get TTL for key '{key}': {e}")
|
||||
|
||||
def incr(self, key: str, amount: int = 1) -> int:
|
||||
"""
|
||||
Increment a key's value by the given amount.
|
||||
|
||||
If the key doesn't exist, it will be created with the increment value.
|
||||
|
||||
Args:
|
||||
key: The key to increment
|
||||
amount: Amount to increment by (default 1)
|
||||
|
||||
Returns:
|
||||
The new value after incrementing
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails or value is not an integer
|
||||
"""
|
||||
try:
|
||||
new_value = self.client.incrby(key, amount)
|
||||
|
||||
logger.debug("Redis INCR", key=key, amount=amount, new_value=new_value)
|
||||
|
||||
return new_value
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis INCR failed", key=key, amount=amount, error=str(e))
|
||||
raise RedisServiceError(f"Failed to increment key '{key}': {e}")
|
||||
|
||||
def decr(self, key: str, amount: int = 1) -> int:
|
||||
"""
|
||||
Decrement a key's value by the given amount.
|
||||
|
||||
If the key doesn't exist, it will be created with the negative increment value.
|
||||
|
||||
Args:
|
||||
key: The key to decrement
|
||||
amount: Amount to decrement by (default 1)
|
||||
|
||||
Returns:
|
||||
The new value after decrementing
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails or value is not an integer
|
||||
"""
|
||||
try:
|
||||
new_value = self.client.decrby(key, amount)
|
||||
|
||||
logger.debug("Redis DECR", key=key, amount=amount, new_value=new_value)
|
||||
|
||||
return new_value
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis DECR failed", key=key, amount=amount, error=str(e))
|
||||
raise RedisServiceError(f"Failed to decrement key '{key}': {e}")
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Redis connection is healthy.
|
||||
|
||||
This performs a PING command to verify the connection is working.
|
||||
|
||||
Returns:
|
||||
True if Redis is healthy and responding, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = self.client.ping()
|
||||
|
||||
if response:
|
||||
logger.debug("Redis health check passed")
|
||||
return True
|
||||
else:
|
||||
logger.warning("Redis health check failed - unexpected response", response=response)
|
||||
return False
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis health check failed", error=str(e))
|
||||
return False
|
||||
|
||||
def info(self) -> dict:
|
||||
"""
|
||||
Get Redis server information.
|
||||
|
||||
Returns:
|
||||
Dictionary containing server info (version, memory, clients, etc.)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
info = self.client.info()
|
||||
|
||||
logger.debug("Redis INFO retrieved", redis_version=info.get('redis_version'))
|
||||
|
||||
return info
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis INFO failed", error=str(e))
|
||||
raise RedisServiceError(f"Failed to get Redis info: {e}")
|
||||
|
||||
def flush_db(self) -> bool:
|
||||
"""
|
||||
Delete all keys in the current database.
|
||||
|
||||
WARNING: This is a destructive operation. Use with caution.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
self.client.flushdb()
|
||||
|
||||
logger.warning("Redis database flushed")
|
||||
|
||||
return True
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis FLUSHDB failed", error=str(e))
|
||||
raise RedisServiceError(f"Failed to flush database: {e}")
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close all connections in the pool.
|
||||
|
||||
Call this when shutting down the application to cleanly release connections.
|
||||
"""
|
||||
try:
|
||||
self.pool.disconnect()
|
||||
logger.info("Redis connection pool closed")
|
||||
except Exception as e:
|
||||
logger.error("Error closing Redis connection pool", error=str(e))
|
||||
|
||||
def _sanitize_url(self, url: str) -> str:
|
||||
"""
|
||||
Remove password from Redis URL for safe logging.
|
||||
|
||||
Args:
|
||||
url: Redis URL that may contain password
|
||||
|
||||
Returns:
|
||||
URL with password masked
|
||||
"""
|
||||
# Simple sanitization - mask password if present
|
||||
# Format: redis://user:password@host:port/db
|
||||
if '@' in url:
|
||||
# Split on @ and mask everything before it except the protocol
|
||||
parts = url.split('@')
|
||||
protocol_and_creds = parts[0]
|
||||
host_and_rest = parts[1]
|
||||
|
||||
if '://' in protocol_and_creds:
|
||||
protocol = protocol_and_creds.split('://')[0]
|
||||
return f"{protocol}://***@{host_and_rest}"
|
||||
|
||||
return url
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit - close connections."""
|
||||
self.close()
|
||||
return False
|
||||
705
api/app/services/session_service.py
Normal file
705
api/app/services/session_service.py
Normal file
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Session Service - CRUD operations for game sessions.
|
||||
|
||||
This service handles creating, reading, updating, and managing game sessions,
|
||||
with support for both solo and multiplayer sessions.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from appwrite.query import Query
|
||||
from appwrite.id import ID
|
||||
|
||||
from app.models.session import GameSession, GameState, ConversationEntry, SessionConfig
|
||||
from app.models.enums import SessionStatus, SessionType
|
||||
from app.models.action_prompt import LocationType
|
||||
from app.services.database_service import get_database_service
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
# Session limits per user
|
||||
MAX_ACTIVE_SESSIONS = 5
|
||||
|
||||
|
||||
class SessionNotFound(Exception):
|
||||
"""Raised when session ID doesn't exist or user doesn't own it."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionLimitExceeded(Exception):
|
||||
"""Raised when user tries to create more sessions than allowed."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionValidationError(Exception):
|
||||
"""Raised when session validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionService:
|
||||
"""
|
||||
Service for managing game sessions.
|
||||
|
||||
This service provides:
|
||||
- Session creation (solo and multiplayer)
|
||||
- Session retrieval and listing
|
||||
- Session state updates
|
||||
- Conversation history management
|
||||
- Game state tracking
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the session service with dependencies."""
|
||||
self.db = get_database_service()
|
||||
self.appwrite = AppwriteService()
|
||||
self.character_service = get_character_service()
|
||||
self.collection_id = "game_sessions"
|
||||
|
||||
logger.info("SessionService initialized")
|
||||
|
||||
def create_solo_session(
|
||||
self,
|
||||
user_id: str,
|
||||
character_id: str,
|
||||
starting_location: Optional[str] = None,
|
||||
starting_location_type: Optional[LocationType] = None
|
||||
) -> GameSession:
|
||||
"""
|
||||
Create a new solo game session.
|
||||
|
||||
This method:
|
||||
1. Validates user owns the character
|
||||
2. Validates user hasn't exceeded session limit
|
||||
3. Determines starting location from location data
|
||||
4. Creates session with initial game state
|
||||
5. Stores in Appwrite database
|
||||
|
||||
Args:
|
||||
user_id: Owner's user ID
|
||||
character_id: Character ID for this session
|
||||
starting_location: Initial location ID (optional, uses default starting location)
|
||||
starting_location_type: Initial location type (optional, derived from location data)
|
||||
|
||||
Returns:
|
||||
Created GameSession instance
|
||||
|
||||
Raises:
|
||||
CharacterNotFound: If character doesn't exist or user doesn't own it
|
||||
SessionLimitExceeded: If user has reached active session limit
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating solo session",
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
# Validate user owns the character
|
||||
character = self.character_service.get_character(character_id, user_id)
|
||||
if not character:
|
||||
raise CharacterNotFound(f"Character not found: {character_id}")
|
||||
|
||||
# Determine starting location from location data if not provided
|
||||
if not starting_location:
|
||||
location_loader = get_location_loader()
|
||||
starting_locations = location_loader.get_starting_locations()
|
||||
|
||||
if starting_locations:
|
||||
# Use first starting location (usually crossville_village)
|
||||
start_loc = starting_locations[0]
|
||||
starting_location = start_loc.location_id
|
||||
# Convert from enums.LocationType to action_prompt.LocationType via string value
|
||||
starting_location_type = LocationType(start_loc.location_type.value)
|
||||
logger.info("Using starting location from data",
|
||||
location_id=starting_location,
|
||||
location_type=starting_location_type.value)
|
||||
else:
|
||||
# Fallback to crossville_village
|
||||
starting_location = "crossville_village"
|
||||
starting_location_type = LocationType.TOWN
|
||||
logger.warning("No starting locations found, using fallback",
|
||||
location_id=starting_location)
|
||||
|
||||
# Ensure location type is set
|
||||
if not starting_location_type:
|
||||
starting_location_type = LocationType.TOWN
|
||||
|
||||
# Check session limit
|
||||
active_count = self.count_user_sessions(user_id, active_only=True)
|
||||
if active_count >= MAX_ACTIVE_SESSIONS:
|
||||
logger.warning("Session limit exceeded",
|
||||
user_id=user_id,
|
||||
current=active_count,
|
||||
limit=MAX_ACTIVE_SESSIONS)
|
||||
raise SessionLimitExceeded(
|
||||
f"Maximum active sessions reached ({active_count}/{MAX_ACTIVE_SESSIONS}). "
|
||||
f"Please end an existing session to start a new one."
|
||||
)
|
||||
|
||||
# Generate unique session ID
|
||||
session_id = ID.unique()
|
||||
|
||||
# Create game state with starting location
|
||||
game_state = GameState(
|
||||
current_location=starting_location,
|
||||
location_type=starting_location_type,
|
||||
discovered_locations=[starting_location],
|
||||
active_quests=[],
|
||||
world_events=[]
|
||||
)
|
||||
|
||||
# Create session instance
|
||||
session = GameSession(
|
||||
session_id=session_id,
|
||||
session_type=SessionType.SOLO,
|
||||
solo_character_id=character_id,
|
||||
user_id=user_id,
|
||||
party_member_ids=[],
|
||||
config=SessionConfig(),
|
||||
game_state=game_state,
|
||||
turn_order=[character_id],
|
||||
current_turn=0,
|
||||
turn_number=0,
|
||||
status=SessionStatus.ACTIVE
|
||||
)
|
||||
|
||||
# Serialize and store
|
||||
session_dict = session.to_dict()
|
||||
session_json = json.dumps(session_dict)
|
||||
|
||||
document_data = {
|
||||
'userId': user_id,
|
||||
'characterId': character_id,
|
||||
'sessionData': session_json,
|
||||
'status': SessionStatus.ACTIVE.value,
|
||||
'sessionType': SessionType.SOLO.value
|
||||
}
|
||||
|
||||
self.db.create_document(
|
||||
collection_id=self.collection_id,
|
||||
data=document_data,
|
||||
document_id=session_id
|
||||
)
|
||||
|
||||
logger.info("Solo session created successfully",
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
return session
|
||||
|
||||
except (CharacterNotFound, SessionLimitExceeded):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to create solo session",
|
||||
user_id=user_id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_session(self, session_id: str, user_id: Optional[str] = None) -> GameSession:
|
||||
"""
|
||||
Get a session by ID.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
user_id: Optional user ID for ownership validation
|
||||
|
||||
Returns:
|
||||
GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionNotFound: If session doesn't exist or user doesn't own it
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching session", session_id=session_id)
|
||||
|
||||
# Get document from database
|
||||
document = self.db.get_row(self.collection_id, session_id)
|
||||
|
||||
if not document:
|
||||
logger.warning("Session not found", session_id=session_id)
|
||||
raise SessionNotFound(f"Session not found: {session_id}")
|
||||
|
||||
# Verify ownership if user_id provided
|
||||
if user_id and document.data.get('userId') != user_id:
|
||||
logger.warning("Session ownership mismatch",
|
||||
session_id=session_id,
|
||||
expected_user=user_id,
|
||||
actual_user=document.data.get('userId'))
|
||||
raise SessionNotFound(f"Session not found: {session_id}")
|
||||
|
||||
# Parse session data
|
||||
session_json = document.data.get('sessionData')
|
||||
session_dict = json.loads(session_json)
|
||||
session = GameSession.from_dict(session_dict)
|
||||
|
||||
logger.debug("Session fetched successfully", session_id=session_id)
|
||||
return session
|
||||
|
||||
except SessionNotFound:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch session",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def update_session(self, session: GameSession) -> GameSession:
|
||||
"""
|
||||
Update a session in the database.
|
||||
|
||||
Args:
|
||||
session: GameSession instance with updated data
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Updating session", session_id=session.session_id)
|
||||
|
||||
# Serialize session
|
||||
session_dict = session.to_dict()
|
||||
session_json = json.dumps(session_dict)
|
||||
|
||||
# Update in database
|
||||
self.db.update_document(
|
||||
collection_id=self.collection_id,
|
||||
document_id=session.session_id,
|
||||
data={
|
||||
'sessionData': session_json,
|
||||
'status': session.status.value
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug("Session updated successfully", session_id=session.session_id)
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update session",
|
||||
session_id=session.session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_user_sessions(
|
||||
self,
|
||||
user_id: str,
|
||||
active_only: bool = True,
|
||||
limit: int = 25
|
||||
) -> List[GameSession]:
|
||||
"""
|
||||
Get all sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
active_only: If True, only return active sessions
|
||||
limit: Maximum number of sessions to return
|
||||
|
||||
Returns:
|
||||
List of GameSession instances
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching user sessions",
|
||||
user_id=user_id,
|
||||
active_only=active_only)
|
||||
|
||||
# Build query
|
||||
queries = [Query.equal('userId', user_id)]
|
||||
if active_only:
|
||||
queries.append(Query.equal('status', SessionStatus.ACTIVE.value))
|
||||
|
||||
documents = self.db.list_rows(
|
||||
table_id=self.collection_id,
|
||||
queries=queries,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# Parse all sessions
|
||||
sessions = []
|
||||
for document in documents:
|
||||
try:
|
||||
session_json = document.data.get('sessionData')
|
||||
session_dict = json.loads(session_json)
|
||||
session = GameSession.from_dict(session_dict)
|
||||
sessions.append(session)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse session",
|
||||
document_id=document.id,
|
||||
error=str(e))
|
||||
continue
|
||||
|
||||
logger.debug("User sessions fetched",
|
||||
user_id=user_id,
|
||||
count=len(sessions))
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch user sessions",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def count_user_sessions(self, user_id: str, active_only: bool = True) -> int:
|
||||
"""
|
||||
Count sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
active_only: If True, only count active sessions
|
||||
|
||||
Returns:
|
||||
Number of sessions
|
||||
"""
|
||||
try:
|
||||
queries = [Query.equal('userId', user_id)]
|
||||
if active_only:
|
||||
queries.append(Query.equal('status', SessionStatus.ACTIVE.value))
|
||||
|
||||
count = self.db.count_documents(
|
||||
collection_id=self.collection_id,
|
||||
queries=queries
|
||||
)
|
||||
|
||||
logger.debug("Session count",
|
||||
user_id=user_id,
|
||||
active_only=active_only,
|
||||
count=count)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to count sessions",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
return 0
|
||||
|
||||
def end_session(self, session_id: str, user_id: str) -> GameSession:
|
||||
"""
|
||||
End a session by marking it as completed.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
user_id: User ID for ownership validation
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionNotFound: If session doesn't exist or user doesn't own it
|
||||
"""
|
||||
try:
|
||||
logger.info("Ending session", session_id=session_id, user_id=user_id)
|
||||
|
||||
session = self.get_session(session_id, user_id)
|
||||
session.status = SessionStatus.COMPLETED
|
||||
session.update_activity()
|
||||
|
||||
return self.update_session(session)
|
||||
|
||||
except SessionNotFound:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to end session",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_conversation_entry(
|
||||
self,
|
||||
session_id: str,
|
||||
character_id: str,
|
||||
character_name: str,
|
||||
action: str,
|
||||
dm_response: str,
|
||||
combat_log: Optional[List] = None,
|
||||
quest_offered: Optional[dict] = None
|
||||
) -> GameSession:
|
||||
"""
|
||||
Add an entry to the conversation history.
|
||||
|
||||
This method automatically:
|
||||
- Increments turn number
|
||||
- Adds timestamp
|
||||
- Updates last activity
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
character_id: Acting character's ID
|
||||
character_name: Acting character's name
|
||||
action: Player's action text
|
||||
dm_response: AI DM's response
|
||||
combat_log: Optional combat actions
|
||||
quest_offered: Optional quest offering info
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Adding conversation entry",
|
||||
session_id=session_id,
|
||||
character_id=character_id)
|
||||
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Create conversation entry
|
||||
entry = ConversationEntry(
|
||||
turn=session.turn_number + 1,
|
||||
character_id=character_id,
|
||||
character_name=character_name,
|
||||
action=action,
|
||||
dm_response=dm_response,
|
||||
combat_log=combat_log or [],
|
||||
quest_offered=quest_offered
|
||||
)
|
||||
|
||||
# Add entry and increment turn
|
||||
session.conversation_history.append(entry)
|
||||
session.turn_number += 1
|
||||
session.update_activity()
|
||||
|
||||
# Save to database
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add conversation entry",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_conversation_history(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0
|
||||
) -> List[ConversationEntry]:
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
limit: Maximum entries to return (None for all)
|
||||
offset: Number of entries to skip from end
|
||||
|
||||
Returns:
|
||||
List of ConversationEntry instances
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
history = session.conversation_history
|
||||
|
||||
# Apply offset (from end)
|
||||
if offset > 0:
|
||||
history = history[:-offset] if offset < len(history) else []
|
||||
|
||||
# Apply limit (from end)
|
||||
if limit and len(history) > limit:
|
||||
history = history[-limit:]
|
||||
|
||||
return history
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get conversation history",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_recent_history(self, session_id: str, num_turns: int = 3) -> List[ConversationEntry]:
|
||||
"""
|
||||
Get the most recent conversation entries for AI context.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
num_turns: Number of recent turns to return
|
||||
|
||||
Returns:
|
||||
List of most recent ConversationEntry instances
|
||||
"""
|
||||
return self.get_conversation_history(session_id, limit=num_turns)
|
||||
|
||||
def update_location(
|
||||
self,
|
||||
session_id: str,
|
||||
new_location: str,
|
||||
location_type: LocationType
|
||||
) -> GameSession:
|
||||
"""
|
||||
Update the current location in the session.
|
||||
|
||||
Also adds location to discovered_locations if not already there.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
new_location: New location name
|
||||
location_type: New location type
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Updating location",
|
||||
session_id=session_id,
|
||||
new_location=new_location)
|
||||
|
||||
session = self.get_session(session_id)
|
||||
session.game_state.current_location = new_location
|
||||
session.game_state.location_type = location_type
|
||||
|
||||
# Track discovered locations
|
||||
if new_location not in session.game_state.discovered_locations:
|
||||
session.game_state.discovered_locations.append(new_location)
|
||||
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update location",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_discovered_location(self, session_id: str, location: str) -> GameSession:
|
||||
"""
|
||||
Add a location to the discovered locations list.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
location: Location name to add
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
if location not in session.game_state.discovered_locations:
|
||||
session.game_state.discovered_locations.append(location)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add discovered location",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_active_quest(self, session_id: str, quest_id: str) -> GameSession:
|
||||
"""
|
||||
Add a quest to the active quests list.
|
||||
|
||||
Validates max 2 active quests limit.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
quest_id: Quest ID to add
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionValidationError: If max quests limit exceeded
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Check max active quests (2)
|
||||
if len(session.game_state.active_quests) >= 2:
|
||||
raise SessionValidationError(
|
||||
"Maximum active quests reached (2/2). "
|
||||
"Complete or abandon a quest to accept a new one."
|
||||
)
|
||||
|
||||
if quest_id not in session.game_state.active_quests:
|
||||
session.game_state.active_quests.append(quest_id)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except SessionValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to add active quest",
|
||||
session_id=session_id,
|
||||
quest_id=quest_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def remove_active_quest(self, session_id: str, quest_id: str) -> GameSession:
|
||||
"""
|
||||
Remove a quest from the active quests list.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
quest_id: Quest ID to remove
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
if quest_id in session.game_state.active_quests:
|
||||
session.game_state.active_quests.remove(quest_id)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove active quest",
|
||||
session_id=session_id,
|
||||
quest_id=quest_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_world_event(self, session_id: str, event: dict) -> GameSession:
|
||||
"""
|
||||
Add a world event to the session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
event: Event dictionary with type, description, etc.
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Add timestamp if not present
|
||||
if 'timestamp' not in event:
|
||||
event['timestamp'] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
session.game_state.world_events.append(event)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add world event",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_service_instance: Optional[SessionService] = None
|
||||
|
||||
|
||||
def get_session_service() -> SessionService:
|
||||
"""
|
||||
Get the global SessionService instance.
|
||||
|
||||
Returns:
|
||||
Singleton SessionService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = SessionService()
|
||||
return _service_instance
|
||||
528
api/app/services/usage_tracking_service.py
Normal file
528
api/app/services/usage_tracking_service.py
Normal file
@@ -0,0 +1,528 @@
|
||||
"""
|
||||
Usage Tracking Service for AI cost and usage monitoring.
|
||||
|
||||
This service tracks all AI usage events, calculates costs, and provides
|
||||
analytics for monitoring and rate limiting purposes.
|
||||
|
||||
Usage:
|
||||
from app.services.usage_tracking_service import UsageTrackingService
|
||||
|
||||
tracker = UsageTrackingService()
|
||||
|
||||
# Log a usage event
|
||||
tracker.log_usage(
|
||||
user_id="user_123",
|
||||
model="anthropic/claude-3.5-sonnet",
|
||||
tokens_input=100,
|
||||
tokens_output=350,
|
||||
task_type=TaskType.STORY_PROGRESSION
|
||||
)
|
||||
|
||||
# Get daily usage
|
||||
usage = tracker.get_daily_usage("user_123", date.today())
|
||||
print(f"Total requests: {usage.total_requests}")
|
||||
print(f"Estimated cost: ${usage.estimated_cost:.4f}")
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone, date, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
from appwrite.exception import AppwriteException
|
||||
from appwrite.id import ID
|
||||
from appwrite.query import Query
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
from app.models.ai_usage import (
|
||||
AIUsageLog,
|
||||
DailyUsageSummary,
|
||||
MonthlyUsageSummary,
|
||||
TaskType
|
||||
)
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
# Cost per 1000 tokens by model (in USD)
|
||||
# These are estimates based on Replicate pricing
|
||||
MODEL_COSTS = {
|
||||
# Llama models (via Replicate) - very cheap
|
||||
"meta/meta-llama-3-8b-instruct": {
|
||||
"input": 0.0001, # $0.0001 per 1K input tokens
|
||||
"output": 0.0001, # $0.0001 per 1K output tokens
|
||||
},
|
||||
"meta/meta-llama-3-70b-instruct": {
|
||||
"input": 0.0006,
|
||||
"output": 0.0006,
|
||||
},
|
||||
# Claude models (via Replicate)
|
||||
"anthropic/claude-3.5-haiku": {
|
||||
"input": 0.001, # $0.001 per 1K input tokens
|
||||
"output": 0.005, # $0.005 per 1K output tokens
|
||||
},
|
||||
"anthropic/claude-3-haiku": {
|
||||
"input": 0.00025,
|
||||
"output": 0.00125,
|
||||
},
|
||||
"anthropic/claude-3.5-sonnet": {
|
||||
"input": 0.003, # $0.003 per 1K input tokens
|
||||
"output": 0.015, # $0.015 per 1K output tokens
|
||||
},
|
||||
"anthropic/claude-4.5-sonnet": {
|
||||
"input": 0.003,
|
||||
"output": 0.015,
|
||||
},
|
||||
"anthropic/claude-3-opus": {
|
||||
"input": 0.015, # $0.015 per 1K input tokens
|
||||
"output": 0.075, # $0.075 per 1K output tokens
|
||||
},
|
||||
}
|
||||
|
||||
# Default cost for unknown models
|
||||
DEFAULT_COST = {"input": 0.001, "output": 0.005}
|
||||
|
||||
|
||||
class UsageTrackingService:
|
||||
"""
|
||||
Service for tracking AI usage and calculating costs.
|
||||
|
||||
This service provides:
|
||||
- Logging individual AI usage events to Appwrite
|
||||
- Calculating estimated costs based on model pricing
|
||||
- Retrieving daily and monthly usage summaries
|
||||
- Analytics for monitoring and rate limiting
|
||||
|
||||
The service stores usage logs in an Appwrite collection named 'ai_usage_logs'.
|
||||
"""
|
||||
|
||||
# Collection ID for usage logs
|
||||
COLLECTION_ID = "ai_usage_logs"
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the usage tracking service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key
|
||||
- APPWRITE_DATABASE_ID: Appwrite database ID
|
||||
|
||||
Raises:
|
||||
ValueError: If required environment variables are missing
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize TablesDB service
|
||||
self.tables_db = TablesDB(self.client)
|
||||
|
||||
logger.info("UsageTrackingService initialized", database_id=self.database_id)
|
||||
|
||||
def log_usage(
|
||||
self,
|
||||
user_id: str,
|
||||
model: str,
|
||||
tokens_input: int,
|
||||
tokens_output: int,
|
||||
task_type: TaskType,
|
||||
session_id: Optional[str] = None,
|
||||
character_id: Optional[str] = None,
|
||||
request_duration_ms: int = 0,
|
||||
success: bool = True,
|
||||
error_message: Optional[str] = None
|
||||
) -> AIUsageLog:
|
||||
"""
|
||||
Log an AI usage event.
|
||||
|
||||
This method creates a new usage log entry in Appwrite with all
|
||||
relevant information about the AI request including calculated
|
||||
estimated cost.
|
||||
|
||||
Args:
|
||||
user_id: User who made the request
|
||||
model: Model identifier (e.g., "anthropic/claude-3.5-sonnet")
|
||||
tokens_input: Number of input tokens (prompt)
|
||||
tokens_output: Number of output tokens (response)
|
||||
task_type: Type of task (story, combat, quest, npc)
|
||||
session_id: Optional game session ID
|
||||
character_id: Optional character ID
|
||||
request_duration_ms: Request duration in milliseconds
|
||||
success: Whether the request succeeded
|
||||
error_message: Error message if failed
|
||||
|
||||
Returns:
|
||||
AIUsageLog with the logged data
|
||||
|
||||
Raises:
|
||||
AppwriteException: If storage fails
|
||||
"""
|
||||
# Calculate total tokens
|
||||
tokens_total = tokens_input + tokens_output
|
||||
|
||||
# Calculate estimated cost
|
||||
estimated_cost = self._calculate_cost(model, tokens_input, tokens_output)
|
||||
|
||||
# Generate log ID
|
||||
log_id = str(uuid4())
|
||||
|
||||
# Create usage log
|
||||
usage_log = AIUsageLog(
|
||||
log_id=log_id,
|
||||
user_id=user_id,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
model=model,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
tokens_total=tokens_total,
|
||||
estimated_cost=estimated_cost,
|
||||
task_type=task_type,
|
||||
session_id=session_id,
|
||||
character_id=character_id,
|
||||
request_duration_ms=request_duration_ms,
|
||||
success=success,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
try:
|
||||
# Store in Appwrite
|
||||
result = self.tables_db.create_row(
|
||||
database_id=self.database_id,
|
||||
table_id=self.COLLECTION_ID,
|
||||
row_id=log_id,
|
||||
data=usage_log.to_dict()
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AI usage logged",
|
||||
log_id=log_id,
|
||||
user_id=user_id,
|
||||
model=model,
|
||||
tokens_total=tokens_total,
|
||||
estimated_cost=estimated_cost,
|
||||
task_type=task_type.value,
|
||||
success=success
|
||||
)
|
||||
|
||||
return usage_log
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error(
|
||||
"Failed to log AI usage",
|
||||
user_id=user_id,
|
||||
model=model,
|
||||
error=str(e),
|
||||
code=e.code
|
||||
)
|
||||
raise
|
||||
|
||||
def get_daily_usage(self, user_id: str, target_date: date) -> DailyUsageSummary:
|
||||
"""
|
||||
Get AI usage summary for a specific day.
|
||||
|
||||
Args:
|
||||
user_id: User ID to get usage for
|
||||
target_date: Date to get usage for
|
||||
|
||||
Returns:
|
||||
DailyUsageSummary with aggregated usage data
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
# Build date range for the target day (UTC)
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||
end_of_day = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Query usage logs for this user and date
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=self.COLLECTION_ID,
|
||||
queries=[
|
||||
Query.equal("user_id", user_id),
|
||||
Query.greater_than_equal("timestamp", start_of_day.isoformat()),
|
||||
Query.less_than_equal("timestamp", end_of_day.isoformat()),
|
||||
Query.limit(1000) # Cap at 1000 entries per day
|
||||
]
|
||||
)
|
||||
|
||||
# Aggregate the data
|
||||
total_requests = 0
|
||||
total_tokens = 0
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
total_cost = 0.0
|
||||
requests_by_task: Dict[str, int] = {}
|
||||
|
||||
for doc in result['rows']:
|
||||
total_requests += 1
|
||||
total_tokens += doc.get('tokens_total', 0)
|
||||
total_input_tokens += doc.get('tokens_input', 0)
|
||||
total_output_tokens += doc.get('tokens_output', 0)
|
||||
total_cost += doc.get('estimated_cost', 0.0)
|
||||
|
||||
task_type = doc.get('task_type', 'general')
|
||||
requests_by_task[task_type] = requests_by_task.get(task_type, 0) + 1
|
||||
|
||||
summary = DailyUsageSummary(
|
||||
date=target_date,
|
||||
user_id=user_id,
|
||||
total_requests=total_requests,
|
||||
total_tokens=total_tokens,
|
||||
total_input_tokens=total_input_tokens,
|
||||
total_output_tokens=total_output_tokens,
|
||||
estimated_cost=total_cost,
|
||||
requests_by_task=requests_by_task
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Daily usage retrieved",
|
||||
user_id=user_id,
|
||||
date=target_date.isoformat(),
|
||||
total_requests=total_requests,
|
||||
estimated_cost=total_cost
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error(
|
||||
"Failed to get daily usage",
|
||||
user_id=user_id,
|
||||
date=target_date.isoformat(),
|
||||
error=str(e),
|
||||
code=e.code
|
||||
)
|
||||
raise
|
||||
|
||||
def get_monthly_cost(self, user_id: str, year: int, month: int) -> MonthlyUsageSummary:
|
||||
"""
|
||||
Get AI usage cost summary for a specific month.
|
||||
|
||||
Args:
|
||||
user_id: User ID to get cost for
|
||||
year: Year (e.g., 2025)
|
||||
month: Month (1-12)
|
||||
|
||||
Returns:
|
||||
MonthlyUsageSummary with aggregated cost data
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
ValueError: If month is invalid
|
||||
"""
|
||||
if not 1 <= month <= 12:
|
||||
raise ValueError(f"Invalid month: {month}. Must be 1-12.")
|
||||
|
||||
try:
|
||||
# Build date range for the month
|
||||
start_of_month = datetime(year, month, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Calculate end of month
|
||||
if month == 12:
|
||||
end_of_month = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - timedelta(seconds=1)
|
||||
else:
|
||||
end_of_month = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=timezone.utc) - timedelta(seconds=1)
|
||||
|
||||
# Query usage logs for this user and month
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=self.COLLECTION_ID,
|
||||
queries=[
|
||||
Query.equal("user_id", user_id),
|
||||
Query.greater_than_equal("timestamp", start_of_month.isoformat()),
|
||||
Query.less_than_equal("timestamp", end_of_month.isoformat()),
|
||||
Query.limit(5000) # Cap at 5000 entries per month
|
||||
]
|
||||
)
|
||||
|
||||
# Aggregate the data
|
||||
total_requests = 0
|
||||
total_tokens = 0
|
||||
total_cost = 0.0
|
||||
|
||||
for doc in result['rows']:
|
||||
total_requests += 1
|
||||
total_tokens += doc.get('tokens_total', 0)
|
||||
total_cost += doc.get('estimated_cost', 0.0)
|
||||
|
||||
summary = MonthlyUsageSummary(
|
||||
year=year,
|
||||
month=month,
|
||||
user_id=user_id,
|
||||
total_requests=total_requests,
|
||||
total_tokens=total_tokens,
|
||||
estimated_cost=total_cost
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Monthly cost retrieved",
|
||||
user_id=user_id,
|
||||
year=year,
|
||||
month=month,
|
||||
total_requests=total_requests,
|
||||
estimated_cost=total_cost
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error(
|
||||
"Failed to get monthly cost",
|
||||
user_id=user_id,
|
||||
year=year,
|
||||
month=month,
|
||||
error=str(e),
|
||||
code=e.code
|
||||
)
|
||||
raise
|
||||
|
||||
def get_total_daily_cost(self, target_date: date) -> float:
|
||||
"""
|
||||
Get the total AI cost across all users for a specific day.
|
||||
|
||||
Used for admin monitoring and alerting.
|
||||
|
||||
Args:
|
||||
target_date: Date to get cost for
|
||||
|
||||
Returns:
|
||||
Total estimated cost in USD
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
# Build date range for the target day
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||
end_of_day = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Query all usage logs for this date
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=self.COLLECTION_ID,
|
||||
queries=[
|
||||
Query.greater_than_equal("timestamp", start_of_day.isoformat()),
|
||||
Query.less_than_equal("timestamp", end_of_day.isoformat()),
|
||||
Query.limit(10000)
|
||||
]
|
||||
)
|
||||
|
||||
# Sum up costs
|
||||
total_cost = sum(doc.get('estimated_cost', 0.0) for doc in result['rows'])
|
||||
|
||||
logger.debug(
|
||||
"Total daily cost retrieved",
|
||||
date=target_date.isoformat(),
|
||||
total_cost=total_cost,
|
||||
total_documents=len(result['rows'])
|
||||
)
|
||||
|
||||
return total_cost
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error(
|
||||
"Failed to get total daily cost",
|
||||
date=target_date.isoformat(),
|
||||
error=str(e),
|
||||
code=e.code
|
||||
)
|
||||
raise
|
||||
|
||||
def get_user_request_count_today(self, user_id: str) -> int:
|
||||
"""
|
||||
Get the number of AI requests a user has made today.
|
||||
|
||||
Used for rate limiting checks.
|
||||
|
||||
Args:
|
||||
user_id: User ID to check
|
||||
|
||||
Returns:
|
||||
Number of requests made today
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
summary = self.get_daily_usage(user_id, date.today())
|
||||
return summary.total_requests
|
||||
|
||||
except AppwriteException:
|
||||
# If there's an error, return 0 to be safe (fail open)
|
||||
logger.warning(
|
||||
"Failed to get user request count, returning 0",
|
||||
user_id=user_id
|
||||
)
|
||||
return 0
|
||||
|
||||
def _calculate_cost(self, model: str, tokens_input: int, tokens_output: int) -> float:
|
||||
"""
|
||||
Calculate the estimated cost for an AI request.
|
||||
|
||||
Args:
|
||||
model: Model identifier
|
||||
tokens_input: Number of input tokens
|
||||
tokens_output: Number of output tokens
|
||||
|
||||
Returns:
|
||||
Estimated cost in USD
|
||||
"""
|
||||
# Get cost per 1K tokens for this model
|
||||
model_cost = MODEL_COSTS.get(model, DEFAULT_COST)
|
||||
|
||||
# Calculate cost (costs are per 1K tokens)
|
||||
input_cost = (tokens_input / 1000) * model_cost["input"]
|
||||
output_cost = (tokens_output / 1000) * model_cost["output"]
|
||||
total_cost = input_cost + output_cost
|
||||
|
||||
return round(total_cost, 6) # Round to 6 decimal places
|
||||
|
||||
@staticmethod
|
||||
def estimate_cost_for_model(model: str, tokens_input: int, tokens_output: int) -> float:
|
||||
"""
|
||||
Static method to estimate cost without needing a service instance.
|
||||
|
||||
Useful for pre-calculation and UI display.
|
||||
|
||||
Args:
|
||||
model: Model identifier
|
||||
tokens_input: Number of input tokens
|
||||
tokens_output: Number of output tokens
|
||||
|
||||
Returns:
|
||||
Estimated cost in USD
|
||||
"""
|
||||
model_cost = MODEL_COSTS.get(model, DEFAULT_COST)
|
||||
input_cost = (tokens_input / 1000) * model_cost["input"]
|
||||
output_cost = (tokens_output / 1000) * model_cost["output"]
|
||||
return round(input_cost + output_cost, 6)
|
||||
|
||||
@staticmethod
|
||||
def get_model_cost_info(model: str) -> Dict[str, float]:
|
||||
"""
|
||||
Get cost information for a model.
|
||||
|
||||
Args:
|
||||
model: Model identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with 'input' and 'output' cost per 1K tokens
|
||||
"""
|
||||
return MODEL_COSTS.get(model, DEFAULT_COST)
|
||||
156
api/app/tasks/__init__.py
Normal file
156
api/app/tasks/__init__.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
RQ Task Queue Configuration
|
||||
|
||||
This module defines the job queues used for background task processing.
|
||||
All async operations (AI generation, combat processing, marketplace tasks)
|
||||
are processed through these queues.
|
||||
|
||||
Queue Types:
|
||||
- ai_tasks: AI narrative generation (highest priority)
|
||||
- combat_tasks: Combat processing
|
||||
- marketplace_tasks: Auction cleanup and periodic tasks (lowest priority)
|
||||
|
||||
Usage:
|
||||
from app.tasks import get_queue, QUEUE_AI_TASKS
|
||||
|
||||
queue = get_queue(QUEUE_AI_TASKS)
|
||||
job = queue.enqueue(my_function, arg1, arg2)
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from redis import Redis
|
||||
from rq import Queue
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
# Queue names
|
||||
QUEUE_AI_TASKS = 'ai_tasks'
|
||||
QUEUE_COMBAT_TASKS = 'combat_tasks'
|
||||
QUEUE_MARKETPLACE_TASKS = 'marketplace_tasks'
|
||||
|
||||
# All queue names in priority order (highest first)
|
||||
ALL_QUEUES = [
|
||||
QUEUE_AI_TASKS,
|
||||
QUEUE_COMBAT_TASKS,
|
||||
QUEUE_MARKETPLACE_TASKS,
|
||||
]
|
||||
|
||||
# Queue configurations
|
||||
QUEUE_CONFIG = {
|
||||
QUEUE_AI_TASKS: {
|
||||
'default_timeout': 120, # 2 minutes for AI generation
|
||||
'default_result_ttl': 3600, # Keep results for 1 hour
|
||||
'default_failure_ttl': 86400, # Keep failures for 24 hours
|
||||
'description': 'AI narrative generation tasks',
|
||||
},
|
||||
QUEUE_COMBAT_TASKS: {
|
||||
'default_timeout': 60, # 1 minute for combat
|
||||
'default_result_ttl': 3600,
|
||||
'default_failure_ttl': 86400,
|
||||
'description': 'Combat processing tasks',
|
||||
},
|
||||
QUEUE_MARKETPLACE_TASKS: {
|
||||
'default_timeout': 300, # 5 minutes for marketplace
|
||||
'default_result_ttl': 3600,
|
||||
'default_failure_ttl': 86400,
|
||||
'description': 'Marketplace and auction tasks',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Redis connection singleton
|
||||
_redis_connection: Optional[Redis] = None
|
||||
|
||||
|
||||
def get_redis_connection() -> Redis:
|
||||
"""
|
||||
Get the Redis connection for RQ.
|
||||
|
||||
Uses a singleton pattern to reuse the connection.
|
||||
|
||||
Returns:
|
||||
Redis connection instance
|
||||
"""
|
||||
global _redis_connection
|
||||
|
||||
if _redis_connection is None:
|
||||
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
_redis_connection = Redis.from_url(redis_url)
|
||||
logger.info("RQ Redis connection established", redis_url=redis_url.split('@')[-1])
|
||||
|
||||
return _redis_connection
|
||||
|
||||
|
||||
def get_queue(queue_name: str) -> Queue:
|
||||
"""
|
||||
Get an RQ queue by name.
|
||||
|
||||
Args:
|
||||
queue_name: Name of the queue (use constants like QUEUE_AI_TASKS)
|
||||
|
||||
Returns:
|
||||
RQ Queue instance
|
||||
|
||||
Raises:
|
||||
ValueError: If queue name is not recognized
|
||||
"""
|
||||
if queue_name not in QUEUE_CONFIG:
|
||||
raise ValueError(f"Unknown queue: {queue_name}. Must be one of {list(QUEUE_CONFIG.keys())}")
|
||||
|
||||
config = QUEUE_CONFIG[queue_name]
|
||||
conn = get_redis_connection()
|
||||
|
||||
return Queue(
|
||||
name=queue_name,
|
||||
connection=conn,
|
||||
default_timeout=config['default_timeout'],
|
||||
)
|
||||
|
||||
|
||||
def get_all_queues() -> list[Queue]:
|
||||
"""
|
||||
Get all configured queues in priority order.
|
||||
|
||||
Returns:
|
||||
List of Queue instances (highest priority first)
|
||||
"""
|
||||
return [get_queue(name) for name in ALL_QUEUES]
|
||||
|
||||
|
||||
def get_queue_info(queue_name: str) -> dict:
|
||||
"""
|
||||
Get information about a queue.
|
||||
|
||||
Args:
|
||||
queue_name: Name of the queue
|
||||
|
||||
Returns:
|
||||
Dictionary with queue statistics
|
||||
"""
|
||||
queue = get_queue(queue_name)
|
||||
config = QUEUE_CONFIG[queue_name]
|
||||
|
||||
return {
|
||||
'name': queue_name,
|
||||
'description': config['description'],
|
||||
'count': len(queue),
|
||||
'default_timeout': config['default_timeout'],
|
||||
'default_result_ttl': config['default_result_ttl'],
|
||||
}
|
||||
|
||||
|
||||
def get_all_queues_info() -> list[dict]:
|
||||
"""
|
||||
Get information about all queues.
|
||||
|
||||
Returns:
|
||||
List of queue info dictionaries
|
||||
"""
|
||||
return [get_queue_info(name) for name in ALL_QUEUES]
|
||||
1314
api/app/tasks/ai_tasks.py
Normal file
1314
api/app/tasks/ai_tasks.py
Normal file
File diff suppressed because it is too large
Load Diff
1
api/app/utils/__init__.py
Normal file
1
api/app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility modules for Code of Conquest."""
|
||||
444
api/app/utils/auth.py
Normal file
444
api/app/utils/auth.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Authentication Utilities
|
||||
|
||||
This module provides authentication middleware, decorators, and helper functions
|
||||
for protecting routes and managing user sessions.
|
||||
|
||||
Usage:
|
||||
from app.utils.auth import require_auth, require_tier, get_current_user
|
||||
|
||||
@app.route('/protected')
|
||||
@require_auth
|
||||
def protected_route():
|
||||
user = get_current_user()
|
||||
return f"Hello, {user.name}!"
|
||||
|
||||
@app.route('/premium-feature')
|
||||
@require_auth
|
||||
@require_tier('premium')
|
||||
def premium_feature():
|
||||
return "Premium content"
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Optional, Callable
|
||||
from flask import request, g, jsonify, redirect, url_for
|
||||
|
||||
from app.services.appwrite_service import AppwriteService, UserData
|
||||
from app.utils.response import unauthorized_response, forbidden_response
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
def extract_session_token() -> Optional[str]:
|
||||
"""
|
||||
Extract the session token from the request cookie.
|
||||
|
||||
Returns:
|
||||
Session token string if found, None otherwise
|
||||
"""
|
||||
config = get_config()
|
||||
cookie_name = config.auth.cookie_name
|
||||
|
||||
token = request.cookies.get(cookie_name)
|
||||
return token
|
||||
|
||||
|
||||
def verify_session(token: str) -> Optional[UserData]:
|
||||
"""
|
||||
Verify a session token and return the associated user data.
|
||||
|
||||
This function:
|
||||
1. Validates the session token with Appwrite
|
||||
2. Checks if the session is still active (not expired)
|
||||
3. Retrieves and returns the user data
|
||||
|
||||
Args:
|
||||
token: Session token from cookie
|
||||
|
||||
Returns:
|
||||
UserData object if session is valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
appwrite = AppwriteService()
|
||||
|
||||
# Validate session
|
||||
session_data = appwrite.get_session(session_id=token)
|
||||
|
||||
# Get user data
|
||||
user_data = appwrite.get_user(user_id=session_data.user_id)
|
||||
return user_data
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.warning("Session verification failed", error=str(e), code=e.code)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during session verification", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user() -> Optional[UserData]:
|
||||
"""
|
||||
Get the current authenticated user from the request context.
|
||||
|
||||
This function retrieves the user object that was attached to the
|
||||
request context by the @require_auth decorator.
|
||||
|
||||
Returns:
|
||||
UserData object if user is authenticated, None otherwise
|
||||
|
||||
Usage:
|
||||
@app.route('/profile')
|
||||
@require_auth
|
||||
def profile():
|
||||
user = get_current_user()
|
||||
return f"Welcome, {user.name}!"
|
||||
"""
|
||||
return getattr(g, 'current_user', None)
|
||||
|
||||
|
||||
def require_auth(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require authentication for a route (API endpoints).
|
||||
|
||||
This decorator:
|
||||
1. Extracts the session token from the cookie
|
||||
2. Verifies the session with Appwrite
|
||||
3. Attaches the user object to the request context (g.current_user)
|
||||
4. Allows the request to proceed if authenticated
|
||||
5. Returns 401 Unauthorized JSON if not authenticated
|
||||
|
||||
For web views, use @require_auth_web instead.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with authentication check
|
||||
|
||||
Usage:
|
||||
@app.route('/api/protected')
|
||||
@require_auth
|
||||
def protected_route():
|
||||
user = get_current_user()
|
||||
return f"Hello, {user.name}!"
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
logger.warning("Authentication required but no session token provided", path=request.path)
|
||||
return unauthorized_response(message="Authentication required. Please log in.")
|
||||
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if not user:
|
||||
logger.warning("Invalid or expired session token", path=request.path)
|
||||
return unauthorized_response(message="Session invalid or expired. Please log in again.")
|
||||
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
|
||||
logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_auth_web(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require authentication for a web view route.
|
||||
|
||||
This decorator:
|
||||
1. Extracts the session token from the cookie
|
||||
2. Verifies the session with Appwrite
|
||||
3. Attaches the user object to the request context (g.current_user)
|
||||
4. Allows the request to proceed if authenticated
|
||||
5. Redirects to login page if not authenticated
|
||||
|
||||
For API endpoints, use @require_auth instead.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with authentication check
|
||||
|
||||
Usage:
|
||||
@app.route('/dashboard')
|
||||
@require_auth_web
|
||||
def dashboard():
|
||||
user = get_current_user()
|
||||
return render_template('dashboard.html', user=user)
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
logger.warning("Authentication required but no session token provided", path=request.path)
|
||||
return redirect(url_for('auth_views.login'))
|
||||
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if not user:
|
||||
logger.warning("Invalid or expired session token", path=request.path)
|
||||
return redirect(url_for('auth_views.login'))
|
||||
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
|
||||
logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_tier(minimum_tier: str) -> Callable:
|
||||
"""
|
||||
Decorator to require a minimum subscription tier for a route.
|
||||
|
||||
This decorator must be used AFTER @require_auth.
|
||||
|
||||
Tier hierarchy (from lowest to highest):
|
||||
- free
|
||||
- basic
|
||||
- premium
|
||||
- elite
|
||||
|
||||
Args:
|
||||
minimum_tier: Minimum required tier (free, basic, premium, elite)
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
|
||||
Raises:
|
||||
ValueError: If minimum_tier is invalid
|
||||
|
||||
Usage:
|
||||
@app.route('/premium-feature')
|
||||
@require_auth
|
||||
@require_tier('premium')
|
||||
def premium_feature():
|
||||
return "Premium content"
|
||||
"""
|
||||
# Define tier hierarchy
|
||||
tier_hierarchy = {
|
||||
'free': 0,
|
||||
'basic': 1,
|
||||
'premium': 2,
|
||||
'elite': 3
|
||||
}
|
||||
|
||||
if minimum_tier not in tier_hierarchy:
|
||||
raise ValueError(f"Invalid tier: {minimum_tier}. Must be one of {list(tier_hierarchy.keys())}")
|
||||
|
||||
def decorator(f: Callable) -> Callable:
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get current user (set by @require_auth)
|
||||
user = get_current_user()
|
||||
|
||||
if not user:
|
||||
logger.error("require_tier used without require_auth", path=request.path)
|
||||
return unauthorized_response(message="Authentication required.")
|
||||
|
||||
# Get user's tier level
|
||||
user_tier = user.tier
|
||||
user_tier_level = tier_hierarchy.get(user_tier, 0)
|
||||
required_tier_level = tier_hierarchy[minimum_tier]
|
||||
|
||||
# Check if user has sufficient tier
|
||||
if user_tier_level < required_tier_level:
|
||||
logger.warning(
|
||||
"Access denied - insufficient tier",
|
||||
user_id=user.id,
|
||||
user_tier=user_tier,
|
||||
required_tier=minimum_tier,
|
||||
path=request.path
|
||||
)
|
||||
return forbidden_response(
|
||||
message=f"This feature requires {minimum_tier.capitalize()} tier or higher. "
|
||||
f"Your current tier: {user_tier.capitalize()}."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Tier requirement met",
|
||||
user_id=user.id,
|
||||
user_tier=user_tier,
|
||||
required_tier=minimum_tier,
|
||||
path=request.path
|
||||
)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_email_verified(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require email verification for a route.
|
||||
|
||||
This decorator must be used AFTER @require_auth.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with email verification check
|
||||
|
||||
Usage:
|
||||
@app.route('/verified-only')
|
||||
@require_auth
|
||||
@require_email_verified
|
||||
def verified_only():
|
||||
return "You can only see this if your email is verified"
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get current user (set by @require_auth)
|
||||
user = get_current_user()
|
||||
|
||||
if not user:
|
||||
logger.error("require_email_verified used without require_auth", path=request.path)
|
||||
return unauthorized_response(message="Authentication required.")
|
||||
|
||||
# Check if email is verified
|
||||
if not user.email_verified:
|
||||
logger.warning(
|
||||
"Access denied - email not verified",
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
path=request.path
|
||||
)
|
||||
return forbidden_response(
|
||||
message="Email verification required. Please check your inbox and verify your email address."
|
||||
)
|
||||
|
||||
logger.debug("Email verification confirmed", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def optional_auth(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator for routes that optionally use authentication.
|
||||
|
||||
This decorator will attach the user to g.current_user if authenticated,
|
||||
but will NOT block the request if not authenticated. Use this for routes
|
||||
that should behave differently based on authentication status.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with optional authentication
|
||||
|
||||
Usage:
|
||||
@app.route('/landing')
|
||||
@optional_auth
|
||||
def landing():
|
||||
user = get_current_user()
|
||||
if user:
|
||||
return f"Welcome back, {user.name}!"
|
||||
else:
|
||||
return "Welcome! Please log in."
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if token:
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if user:
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
logger.debug("Optional auth - user authenticated", user_id=user.id, path=request.path)
|
||||
else:
|
||||
logger.debug("Optional auth - invalid session", path=request.path)
|
||||
else:
|
||||
logger.debug("Optional auth - no session token", path=request.path)
|
||||
|
||||
# Call the original function regardless of authentication
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def get_user_tier() -> str:
|
||||
"""
|
||||
Get the current user's tier.
|
||||
|
||||
Returns:
|
||||
Tier string (free, basic, premium, elite), defaults to 'free' if not authenticated
|
||||
|
||||
Usage:
|
||||
@app.route('/dashboard')
|
||||
@require_auth
|
||||
def dashboard():
|
||||
tier = get_user_tier()
|
||||
return f"Your tier: {tier}"
|
||||
"""
|
||||
user = get_current_user()
|
||||
if user:
|
||||
return user.tier
|
||||
return 'free'
|
||||
|
||||
|
||||
def is_tier_sufficient(required_tier: str) -> bool:
|
||||
"""
|
||||
Check if the current user's tier meets the requirement.
|
||||
|
||||
Args:
|
||||
required_tier: Required tier level
|
||||
|
||||
Returns:
|
||||
True if user's tier is sufficient, False otherwise
|
||||
|
||||
Usage:
|
||||
@app.route('/feature')
|
||||
@require_auth
|
||||
def feature():
|
||||
if is_tier_sufficient('premium'):
|
||||
return "Premium features enabled"
|
||||
else:
|
||||
return "Upgrade to premium for more features"
|
||||
"""
|
||||
tier_hierarchy = {
|
||||
'free': 0,
|
||||
'basic': 1,
|
||||
'premium': 2,
|
||||
'elite': 3
|
||||
}
|
||||
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user_tier_level = tier_hierarchy.get(user.tier, 0)
|
||||
required_tier_level = tier_hierarchy.get(required_tier, 0)
|
||||
|
||||
return user_tier_level >= required_tier_level
|
||||
272
api/app/utils/logging.py
Normal file
272
api/app/utils/logging.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Logging configuration for Code of Conquest.
|
||||
|
||||
Sets up structured logging using structlog with JSON output
|
||||
and context-aware logging throughout the application.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from structlog.stdlib import LoggerFactory
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_level: str = "INFO",
|
||||
log_format: str = "json",
|
||||
log_file: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Configure structured logging for the application.
|
||||
|
||||
Args:
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
log_format: Output format ('json' or 'console')
|
||||
log_file: Optional path to log file
|
||||
|
||||
Example:
|
||||
>>> from app.utils.logging import setup_logging
|
||||
>>> setup_logging(log_level="DEBUG", log_format="json")
|
||||
>>> logger = structlog.get_logger(__name__)
|
||||
>>> logger.info("Application started", version="0.1.0")
|
||||
"""
|
||||
# Convert log level string to logging constant
|
||||
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
|
||||
# Configure standard library logging
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=numeric_level,
|
||||
)
|
||||
|
||||
# Create logs directory if logging to file
|
||||
if log_file:
|
||||
log_path = Path(log_file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Add file handler
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setLevel(numeric_level)
|
||||
logging.root.addHandler(file_handler)
|
||||
|
||||
# Configure structlog processors
|
||||
processors = [
|
||||
# Add log level to event dict
|
||||
structlog.stdlib.add_log_level,
|
||||
# Add logger name to event dict
|
||||
structlog.stdlib.add_logger_name,
|
||||
# Add timestamp
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
# Add stack info for exceptions
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
# Format exceptions
|
||||
structlog.processors.format_exc_info,
|
||||
# Clean up _record and _from_structlog keys
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
]
|
||||
|
||||
# Add format-specific processor
|
||||
if log_format == "json":
|
||||
# JSON output for production
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
else:
|
||||
# Console-friendly output for development
|
||||
processors.append(
|
||||
structlog.dev.ConsoleRenderer(
|
||||
colors=True,
|
||||
exception_formatter=structlog.dev.plain_traceback
|
||||
)
|
||||
)
|
||||
|
||||
# Configure structlog
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
context_class=dict,
|
||||
logger_factory=LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""
|
||||
Get a configured logger instance.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
|
||||
Returns:
|
||||
BoundLogger: Configured structlog logger
|
||||
|
||||
Example:
|
||||
>>> logger = get_logger(__name__)
|
||||
>>> logger.info("User logged in", user_id="123", email="user@example.com")
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
|
||||
|
||||
class LoggerMixin:
|
||||
"""
|
||||
Mixin class to add logging capabilities to any class.
|
||||
|
||||
Provides a `self.logger` attribute with context automatically
|
||||
bound to the class name.
|
||||
|
||||
Example:
|
||||
>>> class MyService(LoggerMixin):
|
||||
... def do_something(self, user_id):
|
||||
... self.logger.info("Doing something", user_id=user_id)
|
||||
"""
|
||||
|
||||
@property
|
||||
def logger(self) -> structlog.stdlib.BoundLogger:
|
||||
"""Get logger for this class."""
|
||||
if not hasattr(self, '_logger'):
|
||||
self._logger = get_logger(self.__class__.__name__)
|
||||
return self._logger
|
||||
|
||||
|
||||
# Common logging utilities
|
||||
|
||||
def log_function_call(logger: structlog.stdlib.BoundLogger):
|
||||
"""
|
||||
Decorator to log function calls with arguments and return values.
|
||||
|
||||
Args:
|
||||
logger: Logger instance to use
|
||||
|
||||
Example:
|
||||
>>> logger = get_logger(__name__)
|
||||
>>> @log_function_call(logger)
|
||||
... def process_data(data_id):
|
||||
... return {"status": "processed"}
|
||||
"""
|
||||
def decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
logger.debug(
|
||||
f"Calling {func.__name__}",
|
||||
function=func.__name__,
|
||||
args=args,
|
||||
kwargs=kwargs
|
||||
)
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
logger.debug(
|
||||
f"{func.__name__} completed",
|
||||
function=func.__name__,
|
||||
result=result
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{func.__name__} failed",
|
||||
function=func.__name__,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def log_ai_call(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
user_id: str,
|
||||
model: str,
|
||||
tier: str,
|
||||
tokens_used: int,
|
||||
cost_estimate: float,
|
||||
context_type: str
|
||||
) -> None:
|
||||
"""
|
||||
Log AI API call for cost tracking and analytics.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
user_id: User making the request
|
||||
model: AI model used
|
||||
tier: Model tier (free, standard, premium)
|
||||
tokens_used: Number of tokens consumed
|
||||
cost_estimate: Estimated cost in USD
|
||||
context_type: Type of context (narrative, combat, etc.)
|
||||
"""
|
||||
logger.info(
|
||||
"AI call completed",
|
||||
event_type="ai_call",
|
||||
user_id=user_id,
|
||||
model=model,
|
||||
tier=tier,
|
||||
tokens_used=tokens_used,
|
||||
cost_estimate=cost_estimate,
|
||||
context_type=context_type
|
||||
)
|
||||
|
||||
|
||||
def log_combat_action(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
session_id: str,
|
||||
character_id: str,
|
||||
action_type: str,
|
||||
target_id: Optional[str] = None,
|
||||
damage: Optional[int] = None,
|
||||
effects: Optional[list] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log combat action for analytics and debugging.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
session_id: Game session ID
|
||||
character_id: Acting character ID
|
||||
action_type: Type of action (attack, cast, item, defend)
|
||||
target_id: Target of action (if applicable)
|
||||
damage: Damage dealt (if applicable)
|
||||
effects: Effects applied (if applicable)
|
||||
"""
|
||||
logger.info(
|
||||
"Combat action executed",
|
||||
event_type="combat_action",
|
||||
session_id=session_id,
|
||||
character_id=character_id,
|
||||
action_type=action_type,
|
||||
target_id=target_id,
|
||||
damage=damage,
|
||||
effects=effects
|
||||
)
|
||||
|
||||
|
||||
def log_marketplace_transaction(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
transaction_id: str,
|
||||
buyer_id: str,
|
||||
seller_id: str,
|
||||
item_id: str,
|
||||
price: int,
|
||||
transaction_type: str
|
||||
) -> None:
|
||||
"""
|
||||
Log marketplace transaction for analytics and auditing.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
transaction_id: Transaction ID
|
||||
buyer_id: Buyer user ID
|
||||
seller_id: Seller user ID
|
||||
item_id: Item ID
|
||||
price: Transaction price
|
||||
transaction_type: Type of transaction
|
||||
"""
|
||||
logger.info(
|
||||
"Marketplace transaction",
|
||||
event_type="marketplace_transaction",
|
||||
transaction_id=transaction_id,
|
||||
buyer_id=buyer_id,
|
||||
seller_id=seller_id,
|
||||
item_id=item_id,
|
||||
price=price,
|
||||
transaction_type=transaction_type
|
||||
)
|
||||
337
api/app/utils/response.py
Normal file
337
api/app/utils/response.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
API response wrapper for Code of Conquest.
|
||||
|
||||
Provides standardized JSON response format for all API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from flask import jsonify, Response
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
def api_response(
|
||||
result: Any = None,
|
||||
status: int = 200,
|
||||
error: Optional[Dict[str, Any]] = None,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a standardized API response.
|
||||
|
||||
Args:
|
||||
result: The response data (or None if error)
|
||||
status: HTTP status code
|
||||
error: Error information (dict with 'code', 'message', 'details')
|
||||
meta: Metadata (pagination, etc.)
|
||||
request_id: Optional request ID for tracking
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return api_response(
|
||||
... result={"user_id": "123"},
|
||||
... status=200
|
||||
... )
|
||||
|
||||
>>> return api_response(
|
||||
... error={
|
||||
... "code": "INVALID_INPUT",
|
||||
... "message": "Email is required",
|
||||
... "details": {"field": "email"}
|
||||
... },
|
||||
... status=400
|
||||
... )
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
response_data = {
|
||||
"app": config.app.name,
|
||||
"version": config.app.version,
|
||||
"status": status,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": request_id,
|
||||
"result": result,
|
||||
"error": error,
|
||||
"meta": meta
|
||||
}
|
||||
|
||||
return jsonify(response_data), status
|
||||
|
||||
|
||||
def success_response(
|
||||
result: Any = None,
|
||||
status: int = 200,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a success response.
|
||||
|
||||
Args:
|
||||
result: The response data
|
||||
status: HTTP status code (default 200)
|
||||
meta: Optional metadata
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return success_response({"character_id": "123"})
|
||||
"""
|
||||
return api_response(
|
||||
result=result,
|
||||
status=status,
|
||||
meta=meta,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def error_response(
|
||||
code: str,
|
||||
message: str,
|
||||
status: int = 400,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create an error response.
|
||||
|
||||
Args:
|
||||
code: Error code (e.g., "INVALID_INPUT")
|
||||
message: Human-readable error message
|
||||
status: HTTP status code (default 400)
|
||||
details: Optional additional error details
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return error_response(
|
||||
... code="NOT_FOUND",
|
||||
... message="Character not found",
|
||||
... status=404
|
||||
... )
|
||||
"""
|
||||
error = {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"details": details or {}
|
||||
}
|
||||
|
||||
return api_response(
|
||||
error=error,
|
||||
status=status,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def created_response(
|
||||
result: Any = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a 201 Created response.
|
||||
|
||||
Args:
|
||||
result: The created resource data
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 201
|
||||
|
||||
Example:
|
||||
>>> return created_response({"character_id": "123"})
|
||||
"""
|
||||
return success_response(
|
||||
result=result,
|
||||
status=201,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def accepted_response(
|
||||
result: Any = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a 202 Accepted response (for async operations).
|
||||
|
||||
Args:
|
||||
result: Job information or status
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 202
|
||||
|
||||
Example:
|
||||
>>> return accepted_response({"job_id": "abc123"})
|
||||
"""
|
||||
return success_response(
|
||||
result=result,
|
||||
status=202,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def no_content_response(request_id: Optional[str] = None) -> Response:
|
||||
"""
|
||||
Create a 204 No Content response.
|
||||
|
||||
Args:
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 204
|
||||
|
||||
Example:
|
||||
>>> return no_content_response()
|
||||
"""
|
||||
return success_response(
|
||||
result=None,
|
||||
status=204,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def paginated_response(
|
||||
items: list,
|
||||
page: int,
|
||||
limit: int,
|
||||
total: int,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a paginated response.
|
||||
|
||||
Args:
|
||||
items: List of items for current page
|
||||
page: Current page number
|
||||
limit: Items per page
|
||||
total: Total number of items
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with pagination metadata
|
||||
|
||||
Example:
|
||||
>>> return paginated_response(
|
||||
... items=[{"id": "1"}, {"id": "2"}],
|
||||
... page=1,
|
||||
... limit=20,
|
||||
... total=100
|
||||
... )
|
||||
"""
|
||||
pages = (total + limit - 1) // limit # Ceiling division
|
||||
|
||||
meta = {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": pages
|
||||
}
|
||||
|
||||
return success_response(
|
||||
result=items,
|
||||
meta=meta,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
# Common error responses
|
||||
|
||||
def unauthorized_response(
|
||||
message: str = "Unauthorized",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""401 Unauthorized response."""
|
||||
return error_response(
|
||||
code="UNAUTHORIZED",
|
||||
message=message,
|
||||
status=401,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def forbidden_response(
|
||||
message: str = "Forbidden",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""403 Forbidden response."""
|
||||
return error_response(
|
||||
code="FORBIDDEN",
|
||||
message=message,
|
||||
status=403,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def not_found_response(
|
||||
message: str = "Resource not found",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""404 Not Found response."""
|
||||
return error_response(
|
||||
code="NOT_FOUND",
|
||||
message=message,
|
||||
status=404,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def validation_error_response(
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""400 Bad Request for validation errors."""
|
||||
return error_response(
|
||||
code="INVALID_INPUT",
|
||||
message=message,
|
||||
status=400,
|
||||
details=details,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def rate_limit_exceeded_response(
|
||||
message: str = "Rate limit exceeded",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""429 Too Many Requests response."""
|
||||
return error_response(
|
||||
code="RATE_LIMIT_EXCEEDED",
|
||||
message=message,
|
||||
status=429,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def internal_error_response(
|
||||
message: str = "Internal server error",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""500 Internal Server Error response."""
|
||||
return error_response(
|
||||
code="INTERNAL_ERROR",
|
||||
message=message,
|
||||
status=500,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def premium_required_response(
|
||||
message: str = "This feature requires a premium subscription",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""403 Forbidden for premium-only features."""
|
||||
return error_response(
|
||||
code="PREMIUM_REQUIRED",
|
||||
message=message,
|
||||
status=403,
|
||||
request_id=request_id
|
||||
)
|
||||
127
api/config/development.yaml
Normal file
127
api/config/development.yaml
Normal 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
126
api/config/production.yaml
Normal 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
77
api/config/rq_config.py
Normal 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
63
api/docker-compose.yml
Normal 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
563
api/docs/ACTION_PROMPTS.md
Normal 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
538
api/docs/AI_INTEGRATION.md
Normal 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
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
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
Reference in New Issue
Block a user