first commit
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Output files (scan reports and screenshots)
|
||||||
|
output/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
#AI helpers
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
app/logs/
|
||||||
|
*.log
|
||||||
284
CLAUDE.md
Normal file
284
CLAUDE.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
"Code Of Conquest" is a web-based AI-powered Dungeons & Dragons style game where Claude acts as the Dungeon Master. Players create characters, explore worlds, engage in turn-based combat, and interact with an AI-driven narrative system.
|
||||||
|
|
||||||
|
**Tech Stack:** Flask + Jinja2 + HTMX + Appwrite + RQ + Redis + Anthropic/Replicate APIs
|
||||||
|
**Target Delivery:** Progressive Web App (PWA)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
This repository contains three independent deployable components:
|
||||||
|
|
||||||
|
- **[/api](api/)** - Flask REST API backend (all business logic, models, services)
|
||||||
|
- **[/public_web](public_web/)** - Flask web frontend (HTML/HTMX UI, calls API)
|
||||||
|
- **[/godot_client](godot_client/)** - Godot game client (native cross-platform)
|
||||||
|
|
||||||
|
## Documentation Index
|
||||||
|
|
||||||
|
**Project-Wide:**
|
||||||
|
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture, tech stack details, component design
|
||||||
|
- **[ROADMAP.md](docs/ROADMAP.md)** - Development roadmap and phases
|
||||||
|
- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Testing, deployment, monitoring, security
|
||||||
|
- **[WEB_VS_CLIENT_SYSTEMS.md](docs/WEB_VS_CLIENT_SYSTEMS.md)** - Feature distribution between web and Godot clients
|
||||||
|
|
||||||
|
**API Backend:**
|
||||||
|
- **[API_REFERENCE.md](api/docs/API_REFERENCE.md)** - API endpoints and response formats
|
||||||
|
- **[DATA_MODELS.md](api/docs/DATA_MODELS.md)** - Character system, items, skills, effects, sessions
|
||||||
|
- **[GAME_SYSTEMS.md](api/docs/GAME_SYSTEMS.md)** - Combat mechanics, marketplace, NPCs
|
||||||
|
- **[QUEST_SYSTEM.md](api/docs/QUEST_SYSTEM.md)** - Quest mechanics and data structures
|
||||||
|
- **[STORY_PROGRESSION.md](api/docs/STORY_PROGRESSION.md)** - Story progression system
|
||||||
|
- **[MULTIPLAYER.md](api/docs/MULTIPLAYER.md)** - Multiplayer session backend logic
|
||||||
|
- **[API_TESTING.md](api/docs/API_TESTING.md)** - API testing guide
|
||||||
|
- **[APPWRITE_SETUP.md](api/docs/APPWRITE_SETUP.md)** - Database setup
|
||||||
|
- **[PHASE4_IMPLEMENTATION.md](api/docs/PHASE4_IMPLEMENTATION.md)** - Phase 4 detailed implementation tasks
|
||||||
|
|
||||||
|
> **Documentation Hierarchy:** `/docs/ROADMAP.md` is the single source for project progress. Service-specific docs (`/api/docs/`, `/public_web/docs/`, `/godot_client/docs/`) contain implementation details.
|
||||||
|
|
||||||
|
**Web Frontend:**
|
||||||
|
- **[TEMPLATES.md](public_web/docs/TEMPLATES.md)** - Template structure and conventions
|
||||||
|
- **[HTMX_PATTERNS.md](public_web/docs/HTMX_PATTERNS.md)** - HTMX integration patterns
|
||||||
|
- **[TESTING.md](public_web/docs/TESTING.md)** - Manual testing guide
|
||||||
|
- **[MULTIPLAYER.md](public_web/docs/MULTIPLAYER.md)** - Multiplayer UI implementation
|
||||||
|
|
||||||
|
**Godot Client:**
|
||||||
|
- **[ARCHITECTURE.md](godot_client/docs/ARCHITECTURE.md)** - Client architecture
|
||||||
|
- **[GETTING_STARTED.md](godot_client/docs/GETTING_STARTED.md)** - Setup and usage
|
||||||
|
- **[EXPORT.md](godot_client/docs/EXPORT.md)** - Platform export guide
|
||||||
|
- **[THEME_SETUP.md](godot_client/docs/THEME_SETUP.md)** - UI theming guide
|
||||||
|
- **[MULTIPLAYER.md](godot_client/docs/MULTIPLAYER.md)** - Multiplayer client implementation
|
||||||
|
- **[scene_char_list.md](godot_client/docs/scene_char_list.md)** - Character list scene implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
**Microservices Architecture:**
|
||||||
|
|
||||||
|
The repository is organized into three independent components:
|
||||||
|
|
||||||
|
1. **`/api`** - REST API Backend
|
||||||
|
- Source code in `/api/app`
|
||||||
|
- All business logic, models, services
|
||||||
|
- Modular organization:
|
||||||
|
- `/api/app/api/` - API endpoint blueprints
|
||||||
|
- `/api/app/models/` - Data models (dataclasses)
|
||||||
|
- `/api/app/services/` - Business logic & integrations
|
||||||
|
- `/api/app/utils/` - Utilities
|
||||||
|
- `/api/app/data/` - Game data (YAML)
|
||||||
|
- Independent deployment with own `requirements.txt`, `config/`, `tests/`
|
||||||
|
|
||||||
|
2. **`/public_web`** - Web Frontend
|
||||||
|
- Source code in `/public_web/app`
|
||||||
|
- Lightweight view layer (makes HTTP calls to API)
|
||||||
|
- Structure:
|
||||||
|
- `/public_web/app/views/` - View blueprints
|
||||||
|
- `/public_web/templates/` - Jinja2 templates
|
||||||
|
- `/public_web/static/` - CSS, JS, images
|
||||||
|
- Independent deployment with own `requirements.txt`, `config/`
|
||||||
|
|
||||||
|
3. **`/godot_client`** - Game Client
|
||||||
|
- Godot 4.5 project
|
||||||
|
- Makes HTTP calls to API
|
||||||
|
- Independent deployment (exports to Desktop/Mobile/Web)
|
||||||
|
|
||||||
|
**Each component:**
|
||||||
|
- Has its own virtual environment
|
||||||
|
- Deploys independently
|
||||||
|
- Shares no code (API is single source of truth)
|
||||||
|
- Typed config loaders (YAML-driven)
|
||||||
|
|
||||||
|
### 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`)
|
||||||
|
|
||||||
|
**Templates & UI**
|
||||||
|
- Don't mix large HTML/CSS blocks in Python code
|
||||||
|
- Prefer Jinja templates for HTML rendering
|
||||||
|
- Clean CSS, minimal inline clutter, readable template logic
|
||||||
|
|
||||||
|
**Writing & Documentation**
|
||||||
|
- Markdown documentation
|
||||||
|
- Clear section headers
|
||||||
|
- Roadmap/Phase/Feature-Session style documents
|
||||||
|
- Boilerplate templates first, then refinements
|
||||||
|
|
||||||
|
**Logging**
|
||||||
|
- Use structlog (pip package)
|
||||||
|
- Setup logging at app start: `logger = logging.get_logger(__file__)`
|
||||||
|
|
||||||
|
**Preferred Pip Packages**
|
||||||
|
- API/Web Server: Flask
|
||||||
|
- HTTP: Requests
|
||||||
|
- Logging: Structlog
|
||||||
|
- Scheduling: APScheduler
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Custom exception classes for domain-specific errors
|
||||||
|
- Consistent error response formats (JSON structure)
|
||||||
|
- Logging severity levels (ERROR vs WARNING)
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Each component has environment-specific configs in its own `/config/*.yaml`
|
||||||
|
- API: `/api/config/development.yaml`, `/api/config/production.yaml`
|
||||||
|
- Web: `/public_web/config/development.yaml`, `/public_web/config/production.yaml`
|
||||||
|
- `.env` for secrets (never committed)
|
||||||
|
- Maintain `.env.example` in each component for documentation
|
||||||
|
- Typed config loaders using dataclasses
|
||||||
|
- Validation on startup
|
||||||
|
|
||||||
|
### Containerization & Deployment
|
||||||
|
- Explicit Dockerfiles
|
||||||
|
- Production-friendly hardening (distroless/slim when meaningful)
|
||||||
|
- Clear build/push scripts that:
|
||||||
|
- Use git branch as tag
|
||||||
|
- Ask whether to tag `:latest`
|
||||||
|
- Ask whether to push
|
||||||
|
- Support private registries
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
- RESTful conventions
|
||||||
|
- Versioning strategy (`/api/v1/...`)
|
||||||
|
- Standardized response format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "<APP NAME>",
|
||||||
|
"version": "<APP VERSION>",
|
||||||
|
"status": <HTTP STATUS CODE>,
|
||||||
|
"timestamp": "<UTC ISO8601>",
|
||||||
|
"request_id": "<optional request id>",
|
||||||
|
"result": <data OR null>,
|
||||||
|
"error": {
|
||||||
|
"code": "<optional machine code>",
|
||||||
|
"message": "<human message>",
|
||||||
|
"details": {}
|
||||||
|
},
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
- Use `requirements.txt` and virtual environments (`python3 -m venv venv`)
|
||||||
|
- Use path `venv` for all virtual environments
|
||||||
|
- Pin versions to version ranges
|
||||||
|
- Activate venv before running code (unless in Docker)
|
||||||
|
|
||||||
|
### Testing Standards
|
||||||
|
- Manual testing preferred for applications
|
||||||
|
- **API Backend:** Maintain `api/docs/API_TESTING.md` with endpoint examples, curl/httpie commands, expected responses
|
||||||
|
- **Unit tests:** Use pytest for API backend (`api/tests/`)
|
||||||
|
- **Web Frontend:** Manual testing checklist in `public_web/README.md`
|
||||||
|
- **Godot Client:** Manual testing via Godot editor
|
||||||
|
|
||||||
|
### Git Standards
|
||||||
|
|
||||||
|
**Branch Strategy:**
|
||||||
|
- `master` - Production-ready code only
|
||||||
|
- `dev` - Main development branch, integration point
|
||||||
|
- `beta` - (Optional) Public pre-release testing
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
- Feature work branches off `dev` (e.g., `feature/add-scheduler`)
|
||||||
|
- Merge features back to `dev` for testing
|
||||||
|
- Promote `dev` → `beta` for public testing (when applicable)
|
||||||
|
- Promote `beta` (or `dev`) → `master` for production
|
||||||
|
|
||||||
|
**Commit Messages:**
|
||||||
|
- Use conventional commit format: `feat:`, `fix:`, `docs:`, `refactor:`, etc.
|
||||||
|
- Keep commits atomic and focused
|
||||||
|
- Write clear, descriptive messages
|
||||||
|
|
||||||
|
**Tagging:**
|
||||||
|
- Tag releases on `master` with semantic versioning (e.g., `v1.2.3`)
|
||||||
|
- Optionally tag beta releases (e.g., `v1.2.3-beta.1`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Preference
|
||||||
|
I follow a pattern: **brainstorm → design → code → revise**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ CLAUDE WORKSPACE BELOW ⚠️
|
||||||
|
|
||||||
|
**The sections above define development preferences and standards.**
|
||||||
|
**Everything below is working context for Claude to track project-specific information, decisions, and progress.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Project Status
|
||||||
|
|
||||||
|
**Status:** Repository Reorganization Complete
|
||||||
|
**Last Updated:** November 17, 2025
|
||||||
|
**Document Version:** 3.0
|
||||||
|
|
||||||
|
### Recent Changes
|
||||||
|
|
||||||
|
**Repository Reorganization (Nov 17, 2025):**
|
||||||
|
- Split monolithic Flask app into microservices architecture
|
||||||
|
- Created three independent components: `/api`, `/public_web`, `/godot_client`
|
||||||
|
- Each component has separate dependencies, configs, and deployment
|
||||||
|
- API backend contains all business logic
|
||||||
|
- Web frontend and Godot client are thin clients that call API
|
||||||
|
|
||||||
|
### Active Decisions Log
|
||||||
|
|
||||||
|
- Using Flask over FastAPI (team familiarity, RQ handles async)
|
||||||
|
- Using RQ over Celery (simpler setup, adequate for scale)
|
||||||
|
- Using Appwrite (reduces infrastructure overhead, built-in auth/realtime)
|
||||||
|
- Using Dataclasses over ORM (flexibility, no migrations, JSON storage)
|
||||||
|
- Turn-based combat (simpler AI prompts, better for multiplayer, classic D&D)
|
||||||
|
- **Microservices architecture** (independent deployment, API as single source of truth)
|
||||||
|
|
||||||
|
### Known Technical Debt
|
||||||
|
|
||||||
|
~~**Public Web Frontend Service Dependencies:** RESOLVED (Nov 21, 2025)~~
|
||||||
|
- All views now use `APIClient` for HTTP requests to API backend
|
||||||
|
- Stub service modules removed
|
||||||
|
- Proper error handling with typed exceptions (`APIError`, `APINotFoundError`, etc.)
|
||||||
|
- Session cookies forwarded to API for authentication
|
||||||
|
|
||||||
|
**Remaining Minor Items:**
|
||||||
|
- Auth decorator doesn't re-validate expired API sessions (low priority)
|
||||||
|
- Origin/class validation fetches full lists instead of single-item lookups (optimization opportunity)
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
1. ~~Refactor `public_web` views to use HTTP API calls~~ ✅ Complete
|
||||||
|
2. Test both API and web frontend independently
|
||||||
|
3. Update Godot client to use new API structure (if needed)
|
||||||
|
4. Continue Phase 4 development (quests, story progression, multiplayer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Claude Code
|
||||||
|
|
||||||
|
When implementing features:
|
||||||
|
|
||||||
|
1. **Start with models** - Define dataclasses first
|
||||||
|
2. **Write tests** - TDD approach for game logic
|
||||||
|
3. **API then UI** - Backend endpoints before frontend
|
||||||
|
4. **Security first** - Validate inputs, check permissions
|
||||||
|
5. **Cost conscious** - Monitor AI usage, implement limits
|
||||||
|
6. **Keep it simple** - Prefer straightforward solutions
|
||||||
|
7. **Document as you go** - Update documentation with decisions
|
||||||
|
|
||||||
|
**Remember:** Developer has strong security expertise (don't compromise security for convenience) and extensive infrastructure experience (focus on application logic).
|
||||||
|
- memorize our godot workflow for the frontend.
|
||||||
363
README.md
Normal file
363
README.md
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
# Code of Conquest
|
||||||
|
|
||||||
|
An AI-powered Dungeons & Dragons style game where Claude acts as the Dungeon Master.
|
||||||
|
|
||||||
|
**Status:** Active Development
|
||||||
|
**Version:** 0.2.0
|
||||||
|
**Architecture:** Microservices (API Backend + Web Frontend + Godot Client)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Code of Conquest is a multi-platform RPG game powered by AI. Players create characters, explore worlds, engage in turn-based combat, and experience dynamic narratives generated by Claude AI.
|
||||||
|
|
||||||
|
**Available Clients:**
|
||||||
|
- **Web Frontend** - Browser-based play via HTMX and Jinja2 templates
|
||||||
|
- **Godot Client** - Native desktop/mobile application
|
||||||
|
|
||||||
|
Both clients communicate with a centralized REST API backend that handles all game logic, AI integration, and data persistence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
This repository contains three independent, deployable components:
|
||||||
|
|
||||||
|
### 1. API Backend (`/api`)
|
||||||
|
|
||||||
|
REST API that serves as the single source of truth for all game logic.
|
||||||
|
|
||||||
|
**Technology:** Flask + Appwrite + RQ + Redis + Anthropic/Replicate APIs
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- All business logic and game mechanics
|
||||||
|
- Character, session, and combat management
|
||||||
|
- AI narrative generation
|
||||||
|
- Authentication and authorization
|
||||||
|
- Background job processing
|
||||||
|
- Database operations
|
||||||
|
|
||||||
|
**Quick Start:**
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # Configure your API keys
|
||||||
|
python wsgi.py # → http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Documentation:** See [`/api/CLAUDE.md`](api/CLAUDE.md) and [`/api/docs/`](api/docs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Web Frontend (`/public_web`)
|
||||||
|
|
||||||
|
Lightweight Flask web application that provides browser-based gameplay.
|
||||||
|
|
||||||
|
**Technology:** Flask + Jinja2 + HTMX + Appwrite Realtime
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- UI/UX rendering (HTML templates)
|
||||||
|
- User interactions (HTMX for AJAX)
|
||||||
|
- Realtime updates (WebSocket subscriptions)
|
||||||
|
- HTTP API calls to backend
|
||||||
|
|
||||||
|
**Quick Start:**
|
||||||
|
```bash
|
||||||
|
cd public_web
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # Configure API URL
|
||||||
|
python wsgi.py # → http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Documentation:** See [`/public_web/CLAUDE.md`](public_web/CLAUDE.md) and [`/public_web/docs/`](public_web/docs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Godot Client (`/godot_client`)
|
||||||
|
|
||||||
|
Native cross-platform game client built with Godot Engine 4.5.
|
||||||
|
|
||||||
|
**Technology:** Godot 4.5 + GDScript
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Rich game UI with animations
|
||||||
|
- Local game rendering
|
||||||
|
- HTTP API calls to backend
|
||||||
|
- Realtime WebSocket subscriptions
|
||||||
|
- Cross-platform exports (Desktop, Mobile, Web)
|
||||||
|
|
||||||
|
**Quick Start:**
|
||||||
|
```bash
|
||||||
|
# Open in Godot Editor
|
||||||
|
godot --editor godot_client/project.godot
|
||||||
|
|
||||||
|
# Or run directly
|
||||||
|
godot godot_client/project.godot
|
||||||
|
```
|
||||||
|
|
||||||
|
**Documentation:** See [`/godot_client/CLAUDE.md`](godot_client/CLAUDE.md) and [`/godot_client/docs/`](godot_client/docs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Index
|
||||||
|
|
||||||
|
### Project-Wide Documentation (`/docs`)
|
||||||
|
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture and tech stack
|
||||||
|
- **[ROADMAP.md](docs/ROADMAP.md)** - Development roadmap and phases
|
||||||
|
- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Deployment, testing, and operations
|
||||||
|
- **[WEB_VS_CLIENT_SYSTEMS.md](docs/WEB_VS_CLIENT_SYSTEMS.md)** - Feature distribution between web and Godot clients
|
||||||
|
|
||||||
|
### API Backend Documentation (`/api/docs`)
|
||||||
|
- **[API_REFERENCE.md](api/docs/API_REFERENCE.md)** - API endpoints and response formats
|
||||||
|
- **[DATA_MODELS.md](api/docs/DATA_MODELS.md)** - Character system, items, skills, effects
|
||||||
|
- **[GAME_SYSTEMS.md](api/docs/GAME_SYSTEMS.md)** - Combat mechanics, marketplace, NPCs
|
||||||
|
- **[QUEST_SYSTEM.md](api/docs/QUEST_SYSTEM.md)** - Quest mechanics and data structures
|
||||||
|
- **[STORY_PROGRESSION.md](api/docs/STORY_PROGRESSION.md)** - Story progression system
|
||||||
|
- **[MULTIPLAYER.md](api/docs/MULTIPLAYER.md)** - Multiplayer session backend logic
|
||||||
|
- **[API_TESTING.md](api/docs/API_TESTING.md)** - API testing guide
|
||||||
|
- **[APPWRITE_SETUP.md](api/docs/APPWRITE_SETUP.md)** - Database setup
|
||||||
|
|
||||||
|
### Web Frontend Documentation (`/public_web/docs`)
|
||||||
|
- **[TEMPLATES.md](public_web/docs/TEMPLATES.md)** - Template structure and conventions
|
||||||
|
- **[HTMX_PATTERNS.md](public_web/docs/HTMX_PATTERNS.md)** - HTMX integration patterns
|
||||||
|
- **[TESTING.md](public_web/docs/TESTING.md)** - Manual testing guide
|
||||||
|
- **[MULTIPLAYER.md](public_web/docs/MULTIPLAYER.md)** - Multiplayer UI implementation
|
||||||
|
|
||||||
|
### Godot Client Documentation (`/godot_client/docs`)
|
||||||
|
- **[ARCHITECTURE.md](godot_client/docs/ARCHITECTURE.md)** - Client architecture
|
||||||
|
- **[GETTING_STARTED.md](godot_client/docs/GETTING_STARTED.md)** - Setup and usage
|
||||||
|
- **[EXPORT.md](godot_client/docs/EXPORT.md)** - Platform export guide
|
||||||
|
- **[THEME_SETUP.md](godot_client/docs/THEME_SETUP.md)** - UI theming guide
|
||||||
|
- **[MULTIPLAYER.md](godot_client/docs/MULTIPLAYER.md)** - Multiplayer client implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### All Components
|
||||||
|
- Git
|
||||||
|
- Docker & Docker Compose (for Redis and Appwrite)
|
||||||
|
|
||||||
|
### API Backend & Web Frontend
|
||||||
|
- Python 3.11+
|
||||||
|
- pip
|
||||||
|
- virtualenv
|
||||||
|
|
||||||
|
### Godot Client
|
||||||
|
- Godot 4.5+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (Full Stack)
|
||||||
|
|
||||||
|
### 1. Start Shared Services
|
||||||
|
|
||||||
|
Both the API backend and web frontend require Redis and Appwrite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start Redis
|
||||||
|
docker-compose up -d redis
|
||||||
|
|
||||||
|
# Start Appwrite (or use Appwrite Cloud)
|
||||||
|
# Follow api/docs/APPWRITE_SETUP.md for configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start API Backend
|
||||||
|
|
||||||
|
```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
|
||||||
|
python scripts/init_database.py # Initialize Appwrite collections
|
||||||
|
python wsgi.py # Runs on http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start Web Frontend (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd public_web
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
# Configure API_BASE_URL=http://localhost:5000
|
||||||
|
python wsgi.py # Runs on http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Start Godot Client (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open in Godot Editor
|
||||||
|
godot --editor godot_client/project.godot
|
||||||
|
|
||||||
|
# Configure API endpoint in project settings
|
||||||
|
# Run the project from editor or export to platform
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Start Background Workers (Optional)
|
||||||
|
|
||||||
|
For AI tasks, combat processing, etc.:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
source venv/bin/activate
|
||||||
|
rq worker ai_tasks combat_tasks marketplace_tasks --url redis://localhost:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ Web Browser │ │ Godot Client │
|
||||||
|
│ (Public Web) │ │ (Desktop/Mobile) │
|
||||||
|
└──────────┬──────────┘ └──────────┬──────────┘
|
||||||
|
│ │
|
||||||
|
│ HTTP (REST API) │ HTTP (REST API)
|
||||||
|
│ WebSocket (Realtime) │ WebSocket (Realtime)
|
||||||
|
│ │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ API Backend │
|
||||||
|
│ (Flask REST API) │
|
||||||
|
│ - Business Logic │
|
||||||
|
│ - Game Mechanics │
|
||||||
|
│ - AI Integration │
|
||||||
|
│ - Auth & Sessions │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Appwrite │ │ Redis │ │ Claude AI│
|
||||||
|
│ Database │ │ Queue │ │ Replicate│
|
||||||
|
└──────────┘ └──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principles:**
|
||||||
|
- **API Backend** is the single source of truth for all game logic
|
||||||
|
- **Web Frontend** and **Godot Client** are thin clients (no business logic)
|
||||||
|
- All clients communicate with API via REST endpoints
|
||||||
|
- Realtime updates via Appwrite Realtime (WebSocket)
|
||||||
|
- Each component deploys independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack Summary
|
||||||
|
|
||||||
|
### API Backend (`/api`)
|
||||||
|
- Flask 3.0+
|
||||||
|
- Python 3.11+
|
||||||
|
- Appwrite (database, auth, realtime)
|
||||||
|
- RQ (Redis Queue) for background jobs
|
||||||
|
- Anthropic Claude API
|
||||||
|
- Replicate API
|
||||||
|
|
||||||
|
### Web Frontend (`/public_web`)
|
||||||
|
- Flask 3.0+ (view layer only)
|
||||||
|
- Jinja2 templates
|
||||||
|
- HTMX (AJAX interactions)
|
||||||
|
- Appwrite JavaScript SDK (realtime)
|
||||||
|
- Vanilla JavaScript
|
||||||
|
|
||||||
|
### Godot Client (`/godot_client`)
|
||||||
|
- Godot Engine 4.5
|
||||||
|
- GDScript
|
||||||
|
- HTTP requests (via HTTPRequest node)
|
||||||
|
- WebSocket (via WebSocketPeer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Development Phase
|
||||||
|
|
||||||
|
**Phase 3:** ✅ Complete - AI Integration & Story Progression
|
||||||
|
- AI-powered narrative generation
|
||||||
|
- Story progression system
|
||||||
|
- Quest system
|
||||||
|
- Combat AI
|
||||||
|
|
||||||
|
**Phase 4:** 🚧 In Progress - Quests, Story Progression, Multiplayer
|
||||||
|
- Quest offering and tracking
|
||||||
|
- Story arc progression
|
||||||
|
- Multiplayer sessions
|
||||||
|
- Godot client implementation
|
||||||
|
|
||||||
|
See [docs/ROADMAP.md](docs/ROADMAP.md) for full development plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
Each component follows its own development guidelines:
|
||||||
|
|
||||||
|
- **API Backend:** See [`api/CLAUDE.md`](api/CLAUDE.md) for backend development standards
|
||||||
|
- **Web Frontend:** See [`public_web/CLAUDE.md`](public_web/CLAUDE.md) for frontend development standards
|
||||||
|
- **Godot Client:** See [`godot_client/CLAUDE.md`](godot_client/CLAUDE.md) for client development standards
|
||||||
|
- **Project-Wide:** See [`CLAUDE.md`](CLAUDE.md) for overall project guidelines
|
||||||
|
|
||||||
|
**Key Standards:**
|
||||||
|
- Microservices architecture (no shared code)
|
||||||
|
- API is the single source of truth
|
||||||
|
- Strong typing throughout (dataclasses, type hints)
|
||||||
|
- Security first (authentication, validation, sanitization)
|
||||||
|
- Cost-conscious AI usage
|
||||||
|
- Comprehensive documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### API Backend
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
pytest
|
||||||
|
# See api/docs/API_TESTING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Frontend
|
||||||
|
Manual testing preferred. See [`public_web/docs/TESTING.md`](public_web/docs/TESTING.md)
|
||||||
|
|
||||||
|
### Godot Client
|
||||||
|
Manual testing via Godot editor. See [`godot_client/docs/README.md`](godot_client/docs/README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Each component deploys independently:
|
||||||
|
|
||||||
|
- **API Backend:** Docker container, Gunicorn, Nginx
|
||||||
|
- **Web Frontend:** Docker container, Gunicorn, Nginx
|
||||||
|
- **Godot Client:** Platform-specific exports (Windows, macOS, Linux, Android, iOS, Web)
|
||||||
|
|
||||||
|
See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for detailed deployment instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
This is a personal project. Contributions are not currently being accepted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Proprietary. All rights reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For questions or feedback, see project documentation in [`/docs`](docs/).
|
||||||
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")
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user