first commit

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

13
public_web/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Public Web Frontend Environment Variables
# Flask environment (development, production)
FLASK_ENV=development
# Secret key for Flask sessions (generate a random string for production)
SECRET_KEY=your-secret-key-here-change-in-production
# API Backend URL (optional, can be set in config YAML)
API_BASE_URL=http://localhost:5000
# Logging
LOG_LEVEL=DEBUG

7
public_web/.flaskenv Normal file
View File

@@ -0,0 +1,7 @@
# Flask CLI Configuration
# This file configures the 'flask run' command
FLASK_APP=wsgi.py
FLASK_ENV=development
FLASK_RUN_HOST=0.0.0.0
FLASK_RUN_PORT=8000

464
public_web/CLAUDE.md Normal file
View File

@@ -0,0 +1,464 @@
# CLAUDE.md - Public Web Frontend
## Service Overview
**Public Web Frontend** for Code of Conquest - Browser-based UI using Flask + Jinja2 + HTMX.
**Tech Stack:** Flask + Jinja2 + HTMX + Vanilla CSS
**Port:** 5001 (development), 8080 (production)
**Location:** `/public_web`
---
## Architecture Role
This web frontend is a **thin UI layer** that makes HTTP requests to the API backend:
- ✅ Render HTML templates (Jinja2)
- ✅ Form validation (UI only)
- ✅ Make HTTP requests to API backend
- ✅ Display API responses
- ✅ Handle user input
**What this service does NOT do:**
- ❌ No business logic (all in `/api`)
- ❌ No direct database access (use API)
- ❌ No direct Appwrite calls (use API)
- ❌ No game mechanics calculations (use API)
**Communication:**
```
User Browser → Public Web (views) → HTTP Request → API Backend
↑ ↓
←─────────── HTTP Response ─────────┘
```
---
## Documentation Index
**Web Frontend Documentation:**
- **[README.md](README.md)** - Setup and usage guide
- **[docs/TEMPLATES.md](docs/TEMPLATES.md)** - Template structure and conventions
- **[docs/HTMX_PATTERNS.md](docs/HTMX_PATTERNS.md)** - HTMX integration patterns
- **[docs/TESTING.md](docs/TESTING.md)** - Manual testing guide
- **[docs/MULTIPLAYER.md](docs/MULTIPLAYER.md)** - Multiplayer UI implementation
**Project-Wide Documentation:**
- **[../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md)** - System architecture overview
- **[../api/docs/API_REFERENCE.md](../api/docs/API_REFERENCE.md)** - API endpoints to call
- **[../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 Guidelines:**
- ✅ Place all public_web service documentation in `/public_web/docs/`
- ✅ Each microservice maintains its own documentation
- ❌ Do NOT add public_web-specific docs to `/docs/` (main repo docs are for cross-service architecture only)
---
## Development Guidelines
### Project Structure
```
public_web/
├── app/ # Application code
│ ├── views/ # View blueprints (Flask routes)
│ └── utils/ # Utilities (logging, auth helpers, API client)
├── templates/ # Jinja2 HTML templates
│ ├── auth/ # Authentication pages
│ ├── character/ # Character pages
│ ├── errors/ # Error pages (404, 500)
│ └── base.html # Base template
├── static/ # CSS, JS, images
│ └── css/ # Stylesheets
├── docs/ # Service-specific documentation
├── config/ # Configuration files
└── README.md # Setup and usage guide
```
### Coding Standards
**Style & Structure**
- Prefer longer, explicit code over compact one-liners
- Always include docstrings for functions/classes + inline comments
- Strong typing for view functions (type hints)
- Keep views simple (delegate to API)
**Templates & UI**
- Don't mix large HTML/CSS blocks in Python code
- Use Jinja2 templates for all HTML rendering
- Clean CSS, minimal inline styles
- Readable template logic (avoid complex Python in templates)
- Use HTMX for dynamic interactions
**Logging**
- Use structlog (pip package)
- Setup logging at app start: `logger = structlog.get_logger(__file__)`
**Preferred Pip Packages**
- Web Server: Flask
- Templates: Jinja2
- HTTP Client: Requests
- Logging: Structlog
### View Development Standards
**View Functions (Flask Routes):**
```python
from flask import Blueprint, render_template, request, redirect, url_for
import requests
from app.config import load_config
character_bp = Blueprint('character', __name__, url_prefix='/characters')
@character_bp.route('/')
def list_characters():
"""
Display list of user's characters.
Makes HTTP request to API backend to fetch character data.
"""
config = load_config()
# Make API request
response = requests.get(
f"{config.api.base_url}/api/v1/characters",
timeout=config.api.timeout
)
if response.status_code == 200:
data = response.json()
characters = data.get('result', [])
return render_template('character/list.html', characters=characters)
else:
# Handle error
return render_template('error.html', message="Failed to load characters"), 500
```
**DO:**
- ✅ Make HTTP requests to API backend
- ✅ Pass data to templates
- ✅ Handle errors gracefully
- ✅ Validate form inputs (UI validation only)
- ✅ Redirect to appropriate pages
- ✅ Use flash messages for user feedback
**DON'T:**
- ❌ No business logic in views
- ❌ No direct database access
- ❌ No game mechanics calculations
- ❌ No complex data transformations (keep it simple)
### Template Best Practices
**Base Template (`templates/base.html`):**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Code of Conquest{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<header>
{% include 'components/navbar.html' %}
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
{% include 'components/footer.html' %}
</footer>
</body>
</html>
```
**Child Template:**
```html
{% extends "base.html" %}
{% block title %}Characters - Code of Conquest{% endblock %}
{% block content %}
<div class="container">
<h1>Your Characters</h1>
{% for character in characters %}
<div class="character-card">
<h2>{{ character.name }}</h2>
<p>{{ character.class_name }} - Level {{ character.level }}</p>
</div>
{% endfor %}
</div>
{% endblock %}
```
**HTMX Usage:**
```html
<!-- Form with HTMX -->
<form hx-post="/api/v1/characters"
hx-target="#character-list"
hx-swap="beforeend">
<input type="text" name="name" placeholder="Character name">
<button type="submit">Create</button>
</form>
<!-- Result container -->
<div id="character-list"></div>
```
### CSS Standards
**Organization:**
- Use BEM naming convention (Block Element Modifier)
- Group related styles together
- Use CSS variables for colors/spacing
- Mobile-first responsive design
**Example:**
```css
:root {
--color-primary: #8b5cf6;
--color-bg: #1a1a1a;
--color-text: #e5e7eb;
--spacing-unit: 1rem;
}
.character-card {
background: var(--color-bg);
color: var(--color-text);
padding: calc(var(--spacing-unit) * 2);
border-radius: 8px;
}
.character-card__title {
font-size: 1.5rem;
margin-bottom: var(--spacing-unit);
}
.character-card--highlighted {
border: 2px solid var(--color-primary);
}
```
### Configuration
- Environment-specific configs in `/config/*.yaml`
- `development.yaml` - Local dev settings (API URL: http://localhost:5000)
- `production.yaml` - Production settings (API URL from env var)
- `.env` for secrets (never committed)
- Typed config loaders using dataclasses
**Configuration Structure:**
```yaml
# config/development.yaml
api:
base_url: "http://localhost:5000"
timeout: 30
verify_ssl: false
server:
host: "0.0.0.0"
port: 5001
workers: 1
session:
lifetime_hours: 24
cookie_secure: false
cookie_httponly: true
```
### Error Handling
**View Error Handling:**
```python
@character_bp.route('/<character_id>')
def character_detail(character_id):
"""Display character details."""
config = load_config()
try:
response = requests.get(
f"{config.api.base_url}/api/v1/characters/{character_id}",
timeout=config.api.timeout
)
response.raise_for_status()
data = response.json()
character = data.get('result')
return render_template('character/detail.html', character=character)
except requests.exceptions.Timeout:
logger.error("API timeout", character_id=character_id)
return render_template('error.html', message="Request timed out"), 504
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
return render_template('error.html', message="Character not found"), 404
logger.error("API error", status=e.response.status_code)
return render_template('error.html', message="An error occurred"), 500
except Exception as e:
logger.exception("Unexpected error", character_id=character_id)
return render_template('error.html', message="An unexpected error occurred"), 500
```
### Dependency Management
- Use `requirements.txt` in `/public_web` directory
- Minimal dependencies (Flask, Jinja2, requests, structlog)
- Use virtual environment: `python3 -m venv venv`
- Activate venv before running: `source venv/bin/activate`
### Testing Standards
**Manual Testing:**
- Use the checklist in README.md
- Test all user flows:
- [ ] Login flow
- [ ] Registration flow
- [ ] Character creation wizard (all 4 steps)
- [ ] Character list and detail views
- [ ] Logout
- [ ] Error handling
**Browser Testing:**
- Test in Chrome, Firefox, Safari
- Test mobile responsive design
- Test HTMX interactions
---
## Architecture Status
**COMPLETE:** All views use the `APIClient` class for HTTP requests to the API backend.
**What's Implemented:**
- All views use `get_api_client()` from `app/utils/api_client.py`
- Typed error handling with `APIError`, `APINotFoundError`, `APITimeoutError`, `APIAuthenticationError`
- Session cookie forwarding for authentication
- Proper JSON serialization/deserialization
**Minor Improvements (Optional):**
- Auth decorator could re-validate expired API sessions
- Origin/class validation could use single-item lookups instead of fetching full lists
---
## Workflow for Web Frontend Development
When implementing new pages:
1. **Design the page** - Sketch layout, user flow
2. **Create template** - Add Jinja2 template in `/templates`
3. **Create view** - Add Flask route in `/app/views`
4. **Make API calls** - Use requests library to call API backend
5. **Handle errors** - Graceful error handling with user feedback
6. **Add styles** - Update CSS in `/static/css`
7. **Test manually** - Check all user flows
**Example Flow:**
```bash
# 1. Create template
# templates/quest/list.html
# 2. Create view
# app/views/quest_views.py
@quest_bp.route('/')
def list_quests():
# Make API request
response = requests.get(f"{api_url}/api/v1/quests")
quests = response.json()['result']
return render_template('quest/list.html', quests=quests)
# 3. Register blueprint
# app/__init__.py
from .views.quest_views import quest_bp
app.register_blueprint(quest_bp)
# 4. Add styles
# static/css/quest.css
# 5. Test in browser
# http://localhost:5001/quests
```
---
## Running the Web Frontend
### Development
**Prerequisites:**
- Python 3.11+
- API backend running at http://localhost:5000
**Setup:**
```bash
cd public_web
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Edit .env with your settings
```
**Run Development Server:**
```bash
source venv/bin/activate
export FLASK_ENV=development
python wsgi.py # → http://localhost:5001
```
### Production
**Run with Gunicorn:**
```bash
gunicorn --bind 0.0.0.0:8080 --workers 4 wsgi:app
```
**Environment Variables:**
```
FLASK_ENV=production
SECRET_KEY=...
API_BASE_URL=https://api.codeofconquest.com
```
---
## Git Standards
**Commit Messages:**
- Use conventional commit format: `feat:`, `fix:`, `docs:`, `style:`, etc.
- Examples:
- `feat(web): add quest list page`
- `fix(views): handle API timeout errors`
- `style(css): improve character card layout`
**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 web frontend:
1. **Thin client only** - No business logic, just UI
2. **Always call API** - Use HTTP requests for all data operations
3. **Handle errors gracefully** - Show user-friendly error messages
4. **Keep templates clean** - Avoid complex logic in Jinja2
5. **Mobile responsive** - Design for all screen sizes
6. **HTMX for interactivity** - Use HTMX instead of heavy JavaScript
7. **Document refactoring needs** - Note any technical debt you encounter
**Remember:**
- This is a thin client - all logic lives in the API backend
- The API serves multiple frontends (this web UI and Godot client)
- Security validation happens in the API, but do basic UI validation for UX
- Keep it simple - complicated logic belongs in the API

220
public_web/README.md Normal file
View File

@@ -0,0 +1,220 @@
# Code of Conquest - Public Web Frontend
Traditional web frontend for Code of Conquest, providing HTML/HTMX UI for browser-based gameplay.
## Overview
This is the **public web frontend** component of Code of Conquest. It provides:
- HTML-based UI using Jinja2 templates
- Interactive forms with HTMX
- Character creation wizard
- Session-based authentication
- Responsive design
**Architecture:** Lightweight view layer that makes HTTP requests to the API backend for all business logic.
## Tech Stack
- **Framework:** Flask 3.x
- **Templates:** Jinja2
- **Interactivity:** HTMX
- **Styling:** Vanilla CSS
- **API Client:** Python requests library
- **Logging:** Structlog
- **WSGI Server:** Gunicorn
## Setup
### Prerequisites
- Python 3.11+
- Running API backend (see `/api`)
### 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 settings
```
### Running Locally
**Development mode:**
```bash
# Activate virtual environment
source venv/bin/activate
# Set environment
export FLASK_ENV=development
# Run development server
python wsgi.py
```
The web UI will be available at `http://localhost:5001`
**Production mode:**
```bash
gunicorn --bind 0.0.0.0:8080 --workers 4 wsgi:app
```
## Configuration
Environment-specific configs are in `/config`:
- `development.yaml` - Local development settings
- `production.yaml` - Production settings
Key settings:
- **Server:** Port (5001 dev, 8080 prod), workers
- **API:** Backend URL, timeout
- **Session:** Cookie settings, lifetime
- **UI:** Theme, pagination
## Project Structure
```
public_web/
├── app/ # Application code
│ ├── views/ # View blueprints (Flask routes)
│ └── utils/ # Utilities (logging, auth helpers, API client)
├── templates/ # Jinja2 HTML templates
│ ├── auth/ # Authentication pages
│ ├── character/ # Character pages
│ ├── errors/ # Error pages (404, 500)
│ └── base.html # Base template
├── static/ # CSS, JS, images
│ └── css/ # Stylesheets
├── docs/ # Service-specific documentation
├── config/ # Configuration files
├── logs/ # Application logs
├── requirements.txt # Python dependencies
├── wsgi.py # WSGI entry point
└── .env.example # Environment template
```
## Features
### Authentication
- Login / Register
- Password reset
- Email verification
- Session management
### Character Management
- Character creation wizard (4 steps)
1. Origin selection
2. Class selection
3. Customization
4. Confirmation
- Character list view
- Character detail page
### UI/UX
- Dark theme
- Responsive design
- HTMX-powered interactivity
- Form validation
- Loading states
## Development
### Adding New Pages
1. Create template in `/templates`:
```html
{% extends "base.html" %}
{% block content %}
<!-- Your content -->
{% endblock %}
```
2. Create view in `/app/views`:
```python
@blueprint.route('/your-route')
def your_view():
return render_template('your_template.html')
```
3. Register blueprint in `/app/__init__.py`
### Making API Calls
All views use the `APIClient` class to communicate with the API backend:
```python
from app.utils.api_client import get_api_client, APIError
api_client = get_api_client()
# GET request
try:
response = api_client.get("/api/v1/characters")
characters = response.get('result', {}).get('characters', [])
except APIError as e:
flash(f'Error: {e.message}', 'error')
# POST request
response = api_client.post("/api/v1/characters", data={
'name': 'Hero',
'class_id': 'warrior',
'origin_id': 'noble'
})
```
The API client handles:
- Session cookie forwarding
- Error handling with typed exceptions (`APIError`, `APINotFoundError`, `APITimeoutError`)
- JSON serialization/deserialization
- SSL verification and timeouts from config
## Testing
Currently, the public web frontend relies on manual testing. Use the API for automated testing.
**Manual testing checklist:**
- [ ] Login flow
- [ ] Registration flow
- [ ] Character creation wizard (all 4 steps)
- [ ] Character list and detail views
- [ ] Logout
- [ ] Error handling
## Deployment
See [DEPLOYMENT.md](../docs/DEPLOYMENT.md) for production deployment instructions.
### Environment Variables
Required environment variables:
- `FLASK_ENV` - development or production
- `SECRET_KEY` - Flask session secret (generate random string)
- `API_BASE_URL` - (Optional) API backend URL
## Related Components
- **API Backend:** `/api` - REST API that this frontend calls
- **Godot Client:** `/godot_client` - Alternative native game client
## Development Guidelines
See [CLAUDE.md](../CLAUDE.md) in the project root for:
- Coding standards
- Template best practices
- Git conventions
- Project workflow
## License
Proprietary - All rights reserved

View File

@@ -0,0 +1,83 @@
"""
Public Web Frontend - Flask Application Factory
This is a lightweight web frontend that provides HTML/HTMX UI for the Code of Conquest game.
All business logic is handled by the API backend - this frontend only renders views and
makes HTTP requests to the API.
"""
from flask import Flask
from flask import render_template
import structlog
import yaml
import os
from pathlib import Path
logger = structlog.get_logger(__name__)
def load_config():
"""Load configuration from YAML file based on environment."""
env = os.getenv("FLASK_ENV", "development")
config_path = Path(__file__).parent.parent / "config" / f"{env}.yaml"
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
logger.info("configuration_loaded", env=env, config_path=str(config_path))
return config
def create_app():
"""Create and configure the Flask application."""
app = Flask(__name__,
template_folder="../templates",
static_folder="../static")
# Load configuration
config = load_config()
app.config.update(config)
# Configure secret key from environment
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
# Context processor to make API config and user available in templates
@app.context_processor
def inject_template_globals():
"""Make API base URL and current user available to all templates."""
from .utils.auth import get_current_user
return {
'api_base_url': app.config.get('api', {}).get('base_url', 'http://localhost:5000'),
'current_user': get_current_user()
}
# Register blueprints
from .views.auth_views import auth_bp
from .views.character_views import character_bp
from .views.game_views import game_bp
app.register_blueprint(auth_bp)
app.register_blueprint(character_bp)
app.register_blueprint(game_bp)
# Register dev blueprint only in development
env = os.getenv("FLASK_ENV", "development")
if env == "development":
from .views.dev import dev_bp
app.register_blueprint(dev_bp)
logger.info("dev_blueprint_registered", message="Dev testing routes available at /dev")
# Error handlers
@app.errorhandler(404)
def not_found(error):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
logger.error("internal_server_error", error=str(error))
return render_template('errors/500.html'), 500
logger.info("flask_app_created", blueprints=["auth", "character", "game"])
return app

87
public_web/app/config.py Normal file
View File

@@ -0,0 +1,87 @@
"""
Configuration loader for Public Web Frontend
Loads environment-specific configuration from YAML files.
"""
import yaml
import os
from pathlib import Path
from dataclasses import dataclass
from typing import Optional
import structlog
logger = structlog.get_logger(__name__)
@dataclass
class ServerConfig:
"""Server configuration settings."""
host: str
port: int
debug: bool
workers: int = 4
@dataclass
class APIConfig:
"""API backend configuration."""
base_url: str
timeout: int = 30
verify_ssl: bool = True
@dataclass
class SessionConfig:
"""Session configuration."""
lifetime_hours: int
cookie_secure: bool
cookie_httponly: bool
cookie_samesite: str
@dataclass
class Config:
"""Main configuration object."""
server: ServerConfig
api: APIConfig
session: SessionConfig
environment: str
def load_config(environment: Optional[str] = None) -> Config:
"""
Load configuration from YAML file.
Args:
environment: Environment name (development, production). If None, uses FLASK_ENV env var.
Returns:
Config object with all settings.
"""
if environment is None:
environment = os.getenv("FLASK_ENV", "development")
config_dir = Path(__file__).parent.parent / "config"
config_path = config_dir / f"{environment}.yaml"
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, 'r') as f:
data = yaml.safe_load(f)
config = Config(
server=ServerConfig(**data['server']),
api=APIConfig(**data['api']),
session=SessionConfig(**data['session']),
environment=environment
)
logger.info("config_loaded",
environment=environment,
api_url=config.api.base_url,
server_port=config.server.port)
return config

View File

@@ -0,0 +1 @@
"""Utility modules for public web frontend."""

View File

@@ -0,0 +1,336 @@
"""
API Client for Public Web Frontend
Provides HTTP request wrapper for communicating with the API backend.
Handles session cookie forwarding, error handling, and response parsing.
"""
import requests
from flask import request as flask_request, session as flask_session
from typing import Optional, Any
from app.config import load_config
from .logging import get_logger
logger = get_logger(__name__)
class APIError(Exception):
"""Base exception for API errors."""
def __init__(self, message: str, status_code: int = 500, details: Optional[dict] = None):
self.message = message
self.status_code = status_code
self.details = details or {}
super().__init__(self.message)
class APITimeoutError(APIError):
"""Raised when API request times out."""
def __init__(self, message: str = "Request timed out"):
super().__init__(message, status_code=504)
class APINotFoundError(APIError):
"""Raised when resource not found (404)."""
def __init__(self, message: str = "Resource not found"):
super().__init__(message, status_code=404)
class APIAuthenticationError(APIError):
"""Raised when authentication fails (401)."""
def __init__(self, message: str = "Authentication required"):
super().__init__(message, status_code=401)
class APIClient:
"""
HTTP client for making requests to the API backend.
Usage:
client = APIClient()
# GET request
response = client.get("/api/v1/characters")
characters = response.get("result", [])
# POST request
response = client.post("/api/v1/characters", data={"name": "Hero"})
# DELETE request
client.delete("/api/v1/characters/123")
"""
def __init__(self):
"""Initialize API client with config."""
self.config = load_config()
self.base_url = self.config.api.base_url.rstrip('/')
self.timeout = self.config.api.timeout
self.verify_ssl = self.config.api.verify_ssl
def _get_cookies(self) -> dict:
"""
Get cookies to forward to API.
Returns:
Dictionary of cookies to forward (session cookie).
"""
cookies = {}
# Get session cookie from Flask session (stored after login)
try:
if 'api_session_cookie' in flask_session:
cookies['coc_session'] = flask_session['api_session_cookie']
except RuntimeError:
# Outside of request context
pass
return cookies
def _save_session_cookie(self, response: requests.Response) -> None:
"""
Save session cookie from API response to Flask session.
Args:
response: Response from requests library.
"""
try:
session_cookie = response.cookies.get('coc_session')
if session_cookie:
flask_session['api_session_cookie'] = session_cookie
logger.debug("Saved API session cookie to Flask session")
except RuntimeError:
# Outside of request context
pass
def _get_headers(self) -> dict:
"""
Get default headers for API requests.
Returns:
Dictionary of headers.
"""
return {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
def _handle_response(self, response: requests.Response) -> dict:
"""
Handle API response and raise appropriate exceptions.
Args:
response: Response from requests library.
Returns:
Parsed JSON response.
Raises:
APIError: For various HTTP error codes.
"""
try:
data = response.json()
except ValueError:
data = {}
# Check for errors
if response.status_code == 401:
error_msg = data.get('error', {}).get('message', 'Authentication required')
raise APIAuthenticationError(error_msg)
if response.status_code == 404:
error_msg = data.get('error', {}).get('message', 'Resource not found')
raise APINotFoundError(error_msg)
if response.status_code >= 400:
error_msg = data.get('error', {}).get('message', f'API error: {response.status_code}')
error_details = data.get('error', {}).get('details', {})
raise APIError(error_msg, response.status_code, error_details)
return data
def get(self, endpoint: str, params: Optional[dict] = None) -> dict:
"""
Make GET request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters").
params: Optional query parameters.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.get(
url,
params=params,
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API GET request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def post(self, endpoint: str, data: Optional[dict] = None) -> dict:
"""
Make POST request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters").
data: Request body data.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.post(
url,
json=data or {},
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API POST request", url=url, status=response.status_code)
# Save session cookie if present (for login responses)
self._save_session_cookie(response)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def delete(self, endpoint: str) -> dict:
"""
Make DELETE request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters/123").
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.delete(
url,
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API DELETE request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def put(self, endpoint: str, data: Optional[dict] = None) -> dict:
"""
Make PUT request to API.
Args:
endpoint: API endpoint.
data: Request body data.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.put(
url,
json=data or {},
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API PUT request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
# Singleton instance
_api_client: Optional[APIClient] = None
def get_api_client() -> APIClient:
"""
Get singleton API client instance.
Returns:
APIClient instance.
"""
global _api_client
if _api_client is None:
_api_client = APIClient()
return _api_client

View File

@@ -0,0 +1,146 @@
"""
Authentication utilities for public web frontend.
Provides authentication checking and decorators for protected routes.
Uses API backend for session validation.
"""
from functools import wraps
from flask import session, redirect, url_for, request, flash
from .logging import get_logger
logger = get_logger(__file__)
# Track last API validation time per session to avoid excessive checks
_SESSION_VALIDATION_KEY = '_api_validated_at'
def get_current_user():
"""
Get the currently authenticated user from session.
Returns:
Dictionary with user data if authenticated, None otherwise.
"""
# Check if we have user in Flask session
if 'user' in session and session.get('user'):
return session['user']
return None
def require_auth_web(f):
"""
Decorator to require authentication for web routes.
Validates the session with the API backend and redirects to
login if not authenticated.
Args:
f: Flask route function
Returns:
Wrapped function that checks authentication
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user = get_current_user()
if user is None:
logger.info("Unauthenticated access attempt", path=request.path)
# Store the intended destination
session['next'] = request.url
return redirect(url_for('auth_views.login'))
return f(*args, **kwargs)
return decorated_function
def clear_user_session():
"""
Clear user session data.
Should be called after logout.
"""
session.pop('user', None)
session.pop('next', None)
session.pop('api_session_cookie', None)
session.pop(_SESSION_VALIDATION_KEY, None)
logger.debug("User session cleared")
def require_auth_strict(revalidate_interval: int = 300):
"""
Decorator to require authentication with API session validation.
This decorator validates the session with the API backend periodically
to ensure the session is still valid on the server side.
Args:
revalidate_interval: Seconds between API validation checks (default 5 minutes).
Set to 0 to validate on every request.
Returns:
Decorator function.
Usage:
@app.route('/protected')
@require_auth_strict() # Validates every 5 minutes
def protected_route():
pass
@app.route('/sensitive')
@require_auth_strict(revalidate_interval=0) # Validates every request
def sensitive_route():
pass
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
import time
from .api_client import get_api_client, APIAuthenticationError, APIError
user = get_current_user()
if user is None:
logger.info("Unauthenticated access attempt", path=request.path)
session['next'] = request.url
return redirect(url_for('auth_views.login'))
# Check if we need to revalidate with API
current_time = time.time()
last_validated = session.get(_SESSION_VALIDATION_KEY, 0)
if revalidate_interval == 0 or (current_time - last_validated) > revalidate_interval:
try:
# Validate session by hitting a lightweight endpoint
api_client = get_api_client()
api_client.get("/api/v1/auth/me")
# Update validation timestamp
session[_SESSION_VALIDATION_KEY] = current_time
session.modified = True
logger.debug("API session validated", user_id=user.get('id'))
except APIAuthenticationError:
# Session expired on server side
logger.warning(
"API session expired",
user_id=user.get('id'),
path=request.path
)
clear_user_session()
flash('Your session has expired. Please log in again.', 'warning')
session['next'] = request.url
return redirect(url_for('auth_views.login'))
except APIError as e:
# API error - log but allow through (fail open for availability)
logger.error(
"API validation error",
user_id=user.get('id'),
error=str(e)
)
# Don't block the user, but don't update validation timestamp
return f(*args, **kwargs)
return decorated_function
return decorator

View File

@@ -0,0 +1,47 @@
"""
Logging utilities for public web frontend.
Simplified logging wrapper using structlog.
"""
import structlog
import logging
import sys
from pathlib import Path
def setup_logging():
"""Configure structured logging for the web frontend."""
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.dev.ConsoleRenderer()
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str):
"""
Get a logger instance.
Args:
name: Logger name (usually __file__)
Returns:
Configured structlog logger
"""
if isinstance(name, str) and name.endswith('.py'):
# Extract module name from file path
name = Path(name).stem
return structlog.get_logger(name)
# Setup logging on module import
setup_logging()

View File

@@ -0,0 +1,3 @@
"""
Views package for Code of Conquest web UI.
"""

View File

@@ -0,0 +1,193 @@
"""
Auth Views Blueprint
This module provides web UI routes for authentication:
- Login page
- Registration page
- Password reset pages
- Email verification
All forms use HTMX to submit to the API endpoints.
"""
from flask import Blueprint, render_template, redirect, url_for, request, session
from app.utils.auth import get_current_user, clear_user_session
from app.utils.logging import get_logger
from app.utils.api_client import get_api_client, APIError
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
auth_bp = Blueprint('auth_views', __name__)
@auth_bp.route('/')
def index():
"""
Landing page / home page.
If user is authenticated, redirect to character list.
Otherwise, redirect to login page.
"""
user = get_current_user()
if user:
logger.info("Authenticated user accessing home, redirecting to characters", user_id=user.get('id'))
return redirect(url_for('character_views.list_characters'))
logger.info("Unauthenticated user accessing home, redirecting to login")
return redirect(url_for('auth_views.login'))
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""
Display login page and handle login.
GET: If user is already authenticated, redirect to character list.
POST: Authenticate via API and set session.
"""
user = get_current_user()
if user:
logger.info("User already authenticated, redirecting to characters", user_id=user.get('id'))
return redirect(url_for('character_views.list_characters'))
if request.method == 'POST':
# Get form data
email = request.form.get('email', '').strip()
password = request.form.get('password', '')
if not email or not password:
return render_template('auth/login.html', error="Email and password are required")
# Call API to authenticate
try:
api_client = get_api_client()
response = api_client.post("/api/v1/auth/login", data={
'email': email,
'password': password
})
# Store user in session
if response.get('result'):
session['user'] = response['result']
logger.info("User logged in successfully", user_id=response['result'].get('id'))
# Redirect to next page or character list
next_url = session.pop('next', None)
if next_url:
return redirect(next_url)
return redirect(url_for('character_views.list_characters'))
except APIError as e:
logger.warning("Login failed", error=str(e))
return render_template('auth/login.html', error=e.message)
logger.info("Rendering login page")
return render_template('auth/login.html')
@auth_bp.route('/register')
def register():
"""
Display registration page.
If user is already authenticated, redirect to character list.
"""
user = get_current_user()
if user:
logger.info("User already authenticated, redirecting to characters", user_id=user.get('id'))
return redirect(url_for('character_views.list_characters'))
logger.info("Rendering registration page")
return render_template('auth/register.html')
@auth_bp.route('/forgot-password')
def forgot_password():
"""
Display forgot password page.
Allows users to request a password reset email.
"""
logger.info("Rendering forgot password page")
return render_template('auth/forgot_password.html')
@auth_bp.route('/reset-password')
def reset_password():
"""
Display password reset page.
This page is accessed via a link in the password reset email.
The reset token should be in the query parameters.
"""
# Get reset token from query parameters
token = request.args.get('token')
user_id = request.args.get('userId')
secret = request.args.get('secret')
if not all([token, user_id, secret]):
logger.warning("Reset password accessed without required parameters")
# Could redirect to forgot-password with an error message
return redirect(url_for('auth_views.forgot_password'))
logger.info("Rendering password reset page", user_id=user_id)
return render_template(
'auth/reset_password.html',
token=token,
user_id=user_id,
secret=secret
)
@auth_bp.route('/verify-email')
def verify_email():
"""
Display email verification page.
This page is accessed via a link in the verification email.
The verification token should be in the query parameters.
"""
# Get verification token from query parameters
token = request.args.get('token')
user_id = request.args.get('userId')
secret = request.args.get('secret')
if not all([token, user_id, secret]):
logger.warning("Email verification accessed without required parameters")
return redirect(url_for('auth_views.login'))
logger.info("Rendering email verification page", user_id=user_id)
return render_template(
'auth/verify_email.html',
token=token,
user_id=user_id,
secret=secret
)
@auth_bp.route('/logout', methods=['POST'])
def logout():
"""
Handle logout by calling API and clearing session.
This is a convenience route for non-HTMX logout forms.
"""
logger.info("Logout initiated via web form")
# Call API to logout (this will invalidate session cookie)
try:
api_client = get_api_client()
api_client.post("/api/v1/auth/logout")
except APIError as e:
logger.error("Failed to call logout API", error=str(e))
# Clear local session
clear_user_session()
return redirect(url_for('auth_views.login'))

View File

@@ -0,0 +1,666 @@
"""
Character Views Blueprint
This module provides web UI routes for character management:
- Character creation flow (4 steps)
- Character list view
- Character detail view
- Skill tree view
All views require authentication and render HTML templates with HTMX.
"""
import time
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
from app.utils.auth import require_auth_web, get_current_user
from app.utils.logging import get_logger
from app.utils.api_client import (
get_api_client,
APIError,
APINotFoundError,
APITimeoutError
)
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
character_bp = Blueprint('character_views', __name__, url_prefix='/characters')
# Cache settings
_CACHE_TTL = 300 # 5 minutes in seconds
_origins_cache = {'data': None, 'timestamp': 0}
_classes_cache = {'data': None, 'timestamp': 0}
# Wizard session timeout (1 hour)
_WIZARD_TIMEOUT = 3600
def _get_cached_origins(api_client):
"""
Get origins list with caching.
Returns cached data if available and fresh, otherwise fetches from API.
Args:
api_client: API client instance.
Returns:
List of origin dictionaries.
"""
global _origins_cache
current_time = time.time()
if _origins_cache['data'] and (current_time - _origins_cache['timestamp']) < _CACHE_TTL:
logger.debug("Using cached origins")
return _origins_cache['data']
# Fetch from API
response = api_client.get("/api/v1/origins")
origins = response.get('result', {}).get('origins', [])
# Update cache
_origins_cache = {'data': origins, 'timestamp': current_time}
logger.debug("Cached origins", count=len(origins))
return origins
def _get_cached_classes(api_client):
"""
Get classes list with caching.
Returns cached data if available and fresh, otherwise fetches from API.
Args:
api_client: API client instance.
Returns:
List of class dictionaries.
"""
global _classes_cache
current_time = time.time()
if _classes_cache['data'] and (current_time - _classes_cache['timestamp']) < _CACHE_TTL:
logger.debug("Using cached classes")
return _classes_cache['data']
# Fetch from API
response = api_client.get("/api/v1/classes")
classes = response.get('result', {}).get('classes', [])
# Update cache
_classes_cache = {'data': classes, 'timestamp': current_time}
logger.debug("Cached classes", count=len(classes))
return classes
def _cleanup_stale_wizard_session():
"""
Clean up stale character creation wizard session data.
Called at the start of character creation to remove abandoned wizard data.
"""
if 'character_creation' in session:
creation_data = session['character_creation']
started_at = creation_data.get('started_at', 0)
current_time = time.time()
if (current_time - started_at) > _WIZARD_TIMEOUT:
logger.info("Cleaning up stale wizard session", age_seconds=int(current_time - started_at))
session.pop('character_creation', None)
# ===== CHARACTER CREATION FLOW =====
@character_bp.route('/create/origin', methods=['GET', 'POST'])
@require_auth_web
def create_origin():
"""
Step 1: Origin Selection
GET: Display all available origins for user to choose from
POST: Save selected origin to session and redirect to class selection
"""
user = get_current_user()
api_client = get_api_client()
# Clean up any stale wizard session from previous attempts
_cleanup_stale_wizard_session()
logger.info("Character creation started - origin selection", user_id=user.get('id'))
if request.method == 'POST':
# Get selected origin from form
origin_id = request.form.get('origin_id')
if not origin_id:
flash('Please select an origin story.', 'error')
return redirect(url_for('character_views.create_origin'))
# Validate origin exists using cached data
try:
origins = _get_cached_origins(api_client)
# Check if selected origin_id is valid
valid_origin = None
for origin in origins:
if origin.get('id') == origin_id:
valid_origin = origin
break
if not valid_origin:
flash('Invalid origin selected.', 'error')
return redirect(url_for('character_views.create_origin'))
except APIError as e:
flash(f'Error validating origin: {e.message}', 'error')
return redirect(url_for('character_views.create_origin'))
# Store in session with timestamp
session['character_creation'] = {
'origin_id': origin_id,
'step': 1,
'started_at': time.time()
}
logger.info("Origin selected", user_id=user.get('id'), origin_id=origin_id)
return redirect(url_for('character_views.create_class'))
# GET: Display origin selection using cached data
try:
origins = _get_cached_origins(api_client)
except APIError as e:
logger.error("Failed to load origins", error=str(e))
flash('Failed to load origins. Please try again.', 'error')
origins = []
return render_template(
'character/create_origin.html',
origins=origins,
current_step=1
)
@character_bp.route('/create/class', methods=['GET', 'POST'])
@require_auth_web
def create_class():
"""
Step 2: Class Selection
GET: Display all available classes for user to choose from
POST: Save selected class to session and redirect to customization
"""
user = get_current_user()
api_client = get_api_client()
# Ensure we have origin selected first
if 'character_creation' not in session or session['character_creation'].get('step', 0) < 1:
flash('Please start from the beginning.', 'warning')
return redirect(url_for('character_views.create_origin'))
if request.method == 'POST':
# Get selected class from form
class_id = request.form.get('class_id')
if not class_id:
flash('Please select a class.', 'error')
return redirect(url_for('character_views.create_class'))
# Validate class exists using cached data
try:
classes = _get_cached_classes(api_client)
# Check if selected class_id is valid
valid_class = None
for player_class in classes:
if player_class.get('class_id') == class_id:
valid_class = player_class
break
if not valid_class:
flash('Invalid class selected.', 'error')
return redirect(url_for('character_views.create_class'))
except APIError as e:
flash(f'Error validating class: {e.message}', 'error')
return redirect(url_for('character_views.create_class'))
# Store in session
session['character_creation']['class_id'] = class_id
session['character_creation']['step'] = 2
session.modified = True
logger.info("Class selected", user_id=user.get('id'), class_id=class_id)
return redirect(url_for('character_views.create_customize'))
# GET: Display class selection using cached data
try:
classes = _get_cached_classes(api_client)
except APIError as e:
logger.error("Failed to load classes", error=str(e))
flash('Failed to load classes. Please try again.', 'error')
classes = []
return render_template(
'character/create_class.html',
classes=classes,
current_step=2
)
@character_bp.route('/create/customize', methods=['GET', 'POST'])
@require_auth_web
def create_customize():
"""
Step 3: Customize Character
GET: Display form to enter character name
POST: Save character name to session and redirect to confirmation
"""
user = get_current_user()
api_client = get_api_client()
# Ensure we have both origin and class selected
if 'character_creation' not in session or session['character_creation'].get('step', 0) < 2:
flash('Please complete previous steps first.', 'warning')
return redirect(url_for('character_views.create_origin'))
if request.method == 'POST':
# Get character name from form
character_name = request.form.get('name', '').strip()
if not character_name:
flash('Please enter a character name.', 'error')
return redirect(url_for('character_views.create_customize'))
# Validate name length (3-30 characters)
if len(character_name) < 3 or len(character_name) > 30:
flash('Character name must be between 3 and 30 characters.', 'error')
return redirect(url_for('character_views.create_customize'))
# Store in session
session['character_creation']['name'] = character_name
session['character_creation']['step'] = 3
session.modified = True
logger.info("Character name entered", user_id=user.get('id'), name=character_name)
return redirect(url_for('character_views.create_confirm'))
# GET: Display customization form
creation_data = session.get('character_creation', {})
# Load origin and class for display using cached data
origin = None
player_class = None
try:
# Find origin in cached list
if creation_data.get('origin_id'):
origins = _get_cached_origins(api_client)
for o in origins:
if o.get('id') == creation_data['origin_id']:
origin = o
break
# Fetch class - can use single endpoint
if creation_data.get('class_id'):
response = api_client.get(f"/api/v1/classes/{creation_data['class_id']}")
player_class = response.get('result')
except APIError as e:
logger.error("Failed to load origin/class data", error=str(e))
return render_template(
'character/create_customize.html',
origin=origin,
player_class=player_class,
current_step=3
)
@character_bp.route('/create/confirm', methods=['GET', 'POST'])
@require_auth_web
def create_confirm():
"""
Step 4: Confirm and Create Character
GET: Display character summary for final confirmation
POST: Create the character via API and redirect to character list
"""
user = get_current_user()
api_client = get_api_client()
# Ensure we have all data
if 'character_creation' not in session or session['character_creation'].get('step', 0) < 3:
flash('Please complete all steps first.', 'warning')
return redirect(url_for('character_views.create_origin'))
creation_data = session.get('character_creation', {})
if request.method == 'POST':
# Create the character via API
try:
response = api_client.post("/api/v1/characters", data={
'name': creation_data['name'],
'class_id': creation_data['class_id'],
'origin_id': creation_data['origin_id']
})
character = response.get('result', {})
# Clear session data
session.pop('character_creation', None)
logger.info(
"Character created successfully",
user_id=user.get('id'),
character_id=character.get('id'),
character_name=character.get('name')
)
flash(f'Character "{character.get("name")}" created successfully!', 'success')
return redirect(url_for('character_views.list_characters'))
except APIError as e:
if 'limit' in e.message.lower():
logger.warning("Character limit exceeded", user_id=user.get('id'), error=str(e))
flash(e.message, 'error')
return redirect(url_for('character_views.list_characters'))
logger.error(
"Failed to create character",
user_id=user.get('id'),
error=str(e)
)
flash('An error occurred while creating your character. Please try again.', 'error')
return redirect(url_for('character_views.create_origin'))
# GET: Display confirmation page using cached data
origin = None
player_class = None
try:
# Find origin in cached list
origins = _get_cached_origins(api_client)
for o in origins:
if o.get('id') == creation_data['origin_id']:
origin = o
break
# Fetch class - can use single endpoint
response = api_client.get(f"/api/v1/classes/{creation_data['class_id']}")
player_class = response.get('result')
except APIError as e:
logger.error("Failed to load origin/class data", error=str(e))
return render_template(
'character/create_confirm.html',
character_name=creation_data['name'],
origin=origin,
player_class=player_class,
current_step=4
)
# ===== CHARACTER MANAGEMENT =====
@character_bp.route('/')
@require_auth_web
def list_characters():
"""
Display list of all characters for the current user.
Also fetches active sessions and maps them to characters.
"""
user = get_current_user()
api_client = get_api_client()
try:
response = api_client.get("/api/v1/characters")
result = response.get('result', {})
# API returns characters in nested structure
characters = result.get('characters', [])
api_tier = result.get('tier', 'free')
api_limit = result.get('limit', 1)
current_tier = api_tier
max_characters = api_limit
can_create = len(characters) < max_characters
# Fetch all user sessions and map to characters
sessions_by_character = {}
try:
sessions_response = api_client.get("/api/v1/sessions")
sessions = sessions_response.get('result', [])
# Handle case where result is a list or a dict with sessions key
if isinstance(sessions, dict):
sessions = sessions.get('sessions', [])
for sess in sessions:
char_id = sess.get('character_id')
if char_id:
if char_id not in sessions_by_character:
sessions_by_character[char_id] = []
sessions_by_character[char_id].append(sess)
except (APIError, APINotFoundError) as e:
# Sessions endpoint may not exist or have issues
logger.debug("Could not fetch sessions", error=str(e))
# Attach sessions to each character
for character in characters:
char_id = character.get('character_id')
character['sessions'] = sessions_by_character.get(char_id, [])
logger.info(
"Characters listed",
user_id=user.get('id'),
count=len(characters),
tier=current_tier
)
return render_template(
'character/list.html',
characters=characters,
current_tier=current_tier,
max_characters=max_characters,
can_create=can_create
)
except APITimeoutError:
logger.error("API timeout while listing characters", user_id=user.get('id'))
flash('Request timed out. Please try again.', 'error')
return render_template('character/list.html', characters=[], can_create=False)
except APIError as e:
logger.error("Failed to list characters", user_id=user.get('id'), error=str(e))
flash('An error occurred while loading your characters.', 'error')
return render_template('character/list.html', characters=[], can_create=False)
@character_bp.route('/<character_id>')
@require_auth_web
def view_character(character_id: str):
"""
Display detailed view of a specific character.
Args:
character_id: ID of the character to view
"""
user = get_current_user()
api_client = get_api_client()
try:
response = api_client.get(f"/api/v1/characters/{character_id}")
character = response.get('result')
logger.info(
"Character viewed",
user_id=user.get('id'),
character_id=character_id
)
return render_template('character/detail.html', character=character)
except APINotFoundError:
logger.warning("Character not found", user_id=user.get('id'), character_id=character_id)
flash('Character not found.', 'error')
return redirect(url_for('character_views.list_characters'))
except APIError as e:
logger.error(
"Failed to view character",
user_id=user.get('id'),
character_id=character_id,
error=str(e)
)
flash('An error occurred while loading the character.', 'error')
return redirect(url_for('character_views.list_characters'))
@character_bp.route('/<character_id>/delete', methods=['POST'])
@require_auth_web
def delete_character(character_id: str):
"""
Delete a character (soft delete - marks as inactive).
Args:
character_id: ID of the character to delete
"""
user = get_current_user()
api_client = get_api_client()
try:
api_client.delete(f"/api/v1/characters/{character_id}")
logger.info("Character deleted", user_id=user.get('id'), character_id=character_id)
flash('Character deleted successfully.', 'success')
except APINotFoundError:
logger.warning("Character not found for deletion", user_id=user.get('id'), character_id=character_id)
flash('Character not found.', 'error')
except APIError as e:
logger.error(
"Failed to delete character",
user_id=user.get('id'),
character_id=character_id,
error=str(e)
)
flash('An error occurred while deleting the character.', 'error')
return redirect(url_for('character_views.list_characters'))
# ===== SESSION MANAGEMENT =====
@character_bp.route('/<character_id>/play', methods=['POST'])
@require_auth_web
def create_session(character_id: str):
"""
Create a new game session for a character and redirect to play screen.
Args:
character_id: ID of the character to start a session with
"""
user = get_current_user()
api_client = get_api_client()
try:
# Create new session via API
response = api_client.post("/api/v1/sessions", data={
'character_id': character_id
})
result = response.get('result', {})
session_id = result.get('session_id')
if not session_id:
flash('Failed to create session - no session ID returned.', 'error')
return redirect(url_for('character_views.list_characters'))
logger.info(
"Session created",
user_id=user.get('id'),
character_id=character_id,
session_id=session_id
)
# Redirect to play screen
return redirect(url_for('game.play_session', session_id=session_id))
except APINotFoundError:
logger.warning("Character not found for session creation", user_id=user.get('id'), character_id=character_id)
flash('Character not found.', 'error')
return redirect(url_for('character_views.list_characters'))
except APIError as e:
logger.error(
"Failed to create session",
user_id=user.get('id'),
character_id=character_id,
error=str(e)
)
# Check for specific errors (session limit, etc.)
if 'limit' in str(e).lower():
flash(f'Session limit reached: {e.message}', 'error')
else:
flash(f'Failed to create session: {e.message}', 'error')
return redirect(url_for('character_views.list_characters'))
@character_bp.route('/<character_id>/skills')
@require_auth_web
def view_skills(character_id: str):
"""
Display skill tree view for a specific character.
Args:
character_id: ID of the character to view skills for
"""
user = get_current_user()
api_client = get_api_client()
try:
# Get character data
response = api_client.get(f"/api/v1/characters/{character_id}")
character = response.get('result')
# Load class data to get skill trees
class_id = character.get('class_id')
player_class = None
if class_id:
response = api_client.get(f"/api/v1/classes/{class_id}")
player_class = response.get('result')
logger.info(
"Skill tree viewed",
user_id=user.get('id'),
character_id=character_id
)
return render_template(
'character/skills.html',
character=character,
player_class=player_class
)
except APINotFoundError:
logger.warning("Character not found for skills view", user_id=user.get('id'), character_id=character_id)
flash('Character not found.', 'error')
return redirect(url_for('character_views.list_characters'))
except APIError as e:
logger.error(
"Failed to view skills",
user_id=user.get('id'),
character_id=character_id,
error=str(e)
)
flash('An error occurred while loading the skill tree.', 'error')
return redirect(url_for('character_views.list_characters'))

382
public_web/app/views/dev.py Normal file
View File

@@ -0,0 +1,382 @@
"""
Development-only views for testing API functionality.
This blueprint only loads when FLASK_ENV=development.
Provides HTMX-based testing interfaces for API endpoints.
"""
from flask import Blueprint, render_template, request, jsonify
import structlog
from ..utils.api_client import get_api_client, APIError, APINotFoundError
from ..utils.auth import require_auth_web as require_auth, get_current_user
logger = structlog.get_logger(__name__)
dev_bp = Blueprint('dev', __name__, url_prefix='/dev')
@dev_bp.route('/')
def index():
"""Dev tools hub - links to all testing interfaces."""
return render_template('dev/index.html')
@dev_bp.route('/story')
@require_auth
def story_hub():
"""Story testing hub - select character and create/load sessions."""
client = get_api_client()
try:
# Get user's characters
characters_response = client.get('/api/v1/characters')
result = characters_response.get('result', {})
characters = result.get('characters', [])
# Get user's active sessions (if endpoint exists)
sessions = []
try:
sessions_response = client.get('/api/v1/sessions')
sessions = sessions_response.get('result', [])
except (APINotFoundError, APIError):
# Sessions list endpoint may not exist yet or has issues
pass
return render_template(
'dev/story.html',
characters=characters,
sessions=sessions
)
except APIError as e:
logger.error("failed_to_load_story_hub", error=str(e))
return render_template('dev/story.html', characters=[], sessions=[], error=str(e))
@dev_bp.route('/story/session/<session_id>')
@require_auth
def story_session(session_id: str):
"""Story session gameplay interface."""
client = get_api_client()
try:
# Get session state
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
# Get session history
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=50')
history_data = history_response.get('result', {})
# Get NPCs at current location
npcs_present = []
game_state = session_data.get('game_state', {})
current_location = game_state.get('current_location_id') or game_state.get('current_location')
if current_location:
try:
npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location}')
npcs_present = npcs_response.get('result', {}).get('npcs', [])
except (APINotFoundError, APIError):
# NPCs endpoint may not exist yet
pass
return render_template(
'dev/story_session.html',
session=session_data,
history=history_data.get('history', []),
session_id=session_id,
npcs_present=npcs_present
)
except APINotFoundError:
return render_template('dev/story.html', error=f"Session {session_id} not found"), 404
except APIError as e:
logger.error("failed_to_load_session", session_id=session_id, error=str(e))
return render_template('dev/story.html', error=str(e)), 500
# HTMX Partial endpoints
@dev_bp.route('/story/create-session', methods=['POST'])
@require_auth
def create_session():
"""Create a new story session - returns HTMX partial."""
client = get_api_client()
character_id = request.form.get('character_id')
logger.info("create_session called",
character_id=character_id,
form_data=dict(request.form))
if not character_id:
return '<div class="error">No character selected</div>', 400
try:
response = client.post('/api/v1/sessions', {'character_id': character_id})
session_data = response.get('result', {})
session_id = session_data.get('session_id')
# Return redirect script to session page
return f'''
<script>window.location.href = '/dev/story/session/{session_id}';</script>
<div class="success">Session created! Redirecting...</div>
'''
except APIError as e:
logger.error("failed_to_create_session", character_id=character_id, error=str(e))
return f'<div class="error">Failed to create session: {e}</div>', 500
@dev_bp.route('/story/action/<session_id>', methods=['POST'])
@require_auth
def take_action(session_id: str):
"""Submit an action - returns job status partial for polling."""
client = get_api_client()
action_type = request.form.get('action_type', 'button')
prompt_id = request.form.get('prompt_id')
custom_text = request.form.get('custom_text')
question = request.form.get('question')
payload = {'action_type': action_type}
if action_type == 'button' and prompt_id:
payload['prompt_id'] = prompt_id
elif action_type == 'custom' and custom_text:
payload['custom_text'] = custom_text
elif action_type == 'ask_dm' and question:
payload['question'] = question
try:
response = client.post(f'/api/v1/sessions/{session_id}/action', payload)
result = response.get('result', {})
job_id = result.get('job_id')
# Return polling partial
return render_template(
'dev/partials/job_status.html',
job_id=job_id,
session_id=session_id,
status='queued'
)
except APIError as e:
logger.error("failed_to_take_action", session_id=session_id, error=str(e))
return f'<div class="error">Action failed: {e}</div>', 500
@dev_bp.route('/story/job-status/<job_id>')
@require_auth
def job_status(job_id: str):
"""Poll job status - returns updated partial."""
client = get_api_client()
session_id = request.args.get('session_id', '')
try:
response = client.get(f'/api/v1/jobs/{job_id}/status')
result = response.get('result', {})
status = result.get('status', 'unknown')
if status == 'completed':
# Job done - return response
# Check for NPC dialogue (in result.dialogue) vs story action (in dm_response)
nested_result = result.get('result', {})
if nested_result.get('context_type') == 'npc_dialogue':
# Use NPC dialogue template with conversation history
return render_template(
'dev/partials/npc_dialogue.html',
npc_name=nested_result.get('npc_name', 'NPC'),
character_name=nested_result.get('character_name', 'You'),
conversation_history=nested_result.get('conversation_history', []),
player_line=nested_result.get('player_line', ''),
dialogue=nested_result.get('dialogue', 'No response'),
session_id=session_id
)
else:
dm_response = result.get('dm_response', 'No response')
return render_template(
'dev/partials/dm_response.html',
dm_response=dm_response,
raw_result=result,
session_id=session_id
)
elif status in ('failed', 'error'):
error_msg = result.get('error', 'Unknown error')
return f'<div class="error">Job failed: {error_msg}</div>'
else:
# Still processing - return polling partial
return render_template(
'dev/partials/job_status.html',
job_id=job_id,
session_id=session_id,
status=status
)
except APIError as e:
logger.error("failed_to_get_job_status", job_id=job_id, error=str(e))
return f'<div class="error">Failed to get job status: {e}</div>', 500
@dev_bp.route('/story/history/<session_id>')
@require_auth
def get_history(session_id: str):
"""Get session history - returns HTMX partial."""
client = get_api_client()
limit = request.args.get('limit', 20, type=int)
offset = request.args.get('offset', 0, type=int)
try:
response = client.get(f'/api/v1/sessions/{session_id}/history?limit={limit}&offset={offset}')
result = response.get('result', {})
return render_template(
'dev/partials/history.html',
history=result.get('history', []),
pagination=result.get('pagination', {}),
session_id=session_id
)
except APIError as e:
logger.error("failed_to_get_history", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load history: {e}</div>', 500
@dev_bp.route('/story/state/<session_id>')
@require_auth
def get_state(session_id: str):
"""Get current session state - returns HTMX partial."""
client = get_api_client()
try:
response = client.get(f'/api/v1/sessions/{session_id}')
session_data = response.get('result', {})
return render_template(
'dev/partials/session_state.html',
session=session_data,
session_id=session_id
)
except APIError as e:
logger.error("failed_to_get_state", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load state: {e}</div>', 500
# ===== NPC & Travel Endpoints =====
@dev_bp.route('/story/talk/<session_id>/<npc_id>', methods=['POST'])
@require_auth
def talk_to_npc(session_id: str, npc_id: str):
"""Talk to an NPC - returns dialogue response."""
client = get_api_client()
# Support both topic (initial greeting) and player_response (conversation)
player_response = request.form.get('player_response')
topic = request.form.get('topic', 'greeting')
try:
payload = {'session_id': session_id}
if player_response:
# Player typed a custom response
payload['player_response'] = player_response
else:
# Initial greeting click
payload['topic'] = topic
response = client.post(f'/api/v1/npcs/{npc_id}/talk', payload)
result = response.get('result', {})
# Check if it's a job-based response (async) or immediate
job_id = result.get('job_id')
if job_id:
return render_template(
'dev/partials/job_status.html',
job_id=job_id,
session_id=session_id,
status='queued',
is_npc_dialogue=True
)
# Immediate response (if AI is sync or cached)
dialogue = result.get('dialogue', result.get('response', 'The NPC has nothing to say.'))
npc_name = result.get('npc_name', 'NPC')
return f'''
<div class="npc-dialogue">
<div class="npc-dialogue-header">{npc_name} says:</div>
<div class="npc-dialogue-content">{dialogue}</div>
</div>
'''
except APINotFoundError:
return '<div class="error">NPC not found.</div>', 404
except APIError as e:
logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e))
return f'<div class="error">Failed to talk to NPC: {e}</div>', 500
@dev_bp.route('/story/travel-modal/<session_id>')
@require_auth
def travel_modal(session_id: str):
"""Get travel modal with available locations."""
client = get_api_client()
try:
response = client.get(f'/api/v1/travel/available?session_id={session_id}')
result = response.get('result', {})
available_locations = result.get('available_locations', [])
return render_template(
'dev/partials/travel_modal.html',
locations=available_locations,
session_id=session_id
)
except APIError as e:
logger.error("failed_to_get_travel_options", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Travel</h3>
<div class="error">Failed to load travel options: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@dev_bp.route('/story/travel/<session_id>', methods=['POST'])
@require_auth
def do_travel(session_id: str):
"""Travel to a new location - returns updated DM response."""
client = get_api_client()
location_id = request.form.get('location_id')
if not location_id:
return '<div class="error">No destination selected.</div>', 400
try:
response = client.post('/api/v1/travel', {
'session_id': session_id,
'location_id': location_id
})
result = response.get('result', {})
# Check if travel triggers a job (narrative generation)
job_id = result.get('job_id')
if job_id:
return render_template(
'dev/partials/job_status.html',
job_id=job_id,
session_id=session_id,
status='queued'
)
# Immediate response
narrative = result.get('narrative', result.get('description', 'You arrive at your destination.'))
location_name = result.get('location_name', 'Unknown Location')
# Return script to close modal and update response
return f'''
<script>
document.querySelector('.modal-overlay')?.remove();
</script>
<div>
<strong>Arrived at {location_name}</strong><br><br>
{narrative}
</div>
'''
except APIError as e:
logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e))
return f'<div class="error">Travel failed: {e}</div>', 500

View File

@@ -0,0 +1,796 @@
"""
Production game views for the play screen.
Provides the main gameplay interface with 3-column layout:
- Left: Character stats + action buttons
- Middle: Narrative + location context
- Right: Accordions for history, quests, NPCs, map
"""
from flask import Blueprint, render_template, request
import structlog
from ..utils.api_client import get_api_client, APIError, APINotFoundError
from ..utils.auth import require_auth_web as require_auth, get_current_user
logger = structlog.get_logger(__name__)
game_bp = Blueprint('game', __name__, url_prefix='/play')
# ===== Action Definitions =====
# Actions organized by tier - context filtering happens in template
# These are static definitions, available actions come from API session state
DEFAULT_ACTIONS = {
'free': [
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
{'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']},
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
],
'premium': [
{'prompt_id': 'investigate_suspicious', 'display_text': 'Investigate Suspicious Activity', 'icon': 'magnifying_glass', 'context': ['any']},
{'prompt_id': 'follow_lead', 'display_text': 'Follow a Lead', 'icon': 'footprints', 'context': ['any']},
{'prompt_id': 'make_camp', 'display_text': 'Make Camp', 'icon': 'campfire', 'context': ['wilderness'], 'cooldown': 5}
],
'elite': [
{'prompt_id': 'consult_texts', 'display_text': 'Consult Ancient Texts', 'icon': 'book', 'context': ['library', 'town'], 'cooldown': 3},
{'prompt_id': 'commune_nature', 'display_text': 'Commune with Nature', 'icon': 'leaf', 'context': ['wilderness'], 'cooldown': 4},
{'prompt_id': 'seek_audience', 'display_text': 'Seek Audience with Authorities', 'icon': 'crown', 'context': ['town'], 'cooldown': 5}
]
}
def _get_user_tier(client) -> str:
"""Get user's subscription tier from API or session."""
try:
# Try to get user info which includes tier
user_response = client.get('/api/v1/auth/me')
user_data = user_response.get('result', {})
return user_data.get('tier', 'free')
except (APIError, APINotFoundError):
# Default to free tier if we can't determine
return 'free'
def _build_location_from_game_state(game_state: dict) -> dict:
"""Build location dict from game_state data."""
return {
'location_id': game_state.get('current_location_id') or game_state.get('current_location'),
'name': game_state.get('current_location_name', game_state.get('current_location', 'Unknown')),
'location_type': game_state.get('location_type', 'unknown'),
'region': game_state.get('region', ''),
'description': game_state.get('location_description', ''),
'ambient_description': game_state.get('ambient_description', '')
}
def _build_character_from_api(char_data: dict) -> dict:
"""
Build character dict from API character response.
Always returns a dict with all required fields, using sensible defaults
if the API data is incomplete or empty.
"""
if not char_data:
char_data = {}
# Extract stats from base_stats or stats, with defaults
stats = char_data.get('base_stats', char_data.get('stats', {}))
if not stats:
stats = {
'strength': 10,
'dexterity': 10,
'constitution': 10,
'intelligence': 10,
'wisdom': 10,
'charisma': 10
}
# Calculate HP/MP - these may come from different places
# For now use defaults based on level/constitution
level = char_data.get('level', 1)
constitution = stats.get('constitution', 10)
intelligence = stats.get('intelligence', 10)
# Simple HP/MP calculation (can be refined based on game rules)
max_hp = max(1, 50 + (level * 10) + ((constitution - 10) * level))
max_mp = max(1, 20 + (level * 5) + ((intelligence - 10) * level // 2))
# Get class name from various possible locations
class_name = 'Unknown'
if char_data.get('player_class'):
class_name = char_data['player_class'].get('name', 'Unknown')
elif char_data.get('class_name'):
class_name = char_data['class_name']
elif char_data.get('class'):
class_name = char_data['class'].replace('_', ' ').title()
return {
'character_id': char_data.get('character_id', ''),
'name': char_data.get('name', 'Unknown Hero'),
'class_name': class_name,
'level': level,
'current_hp': char_data.get('current_hp', max_hp),
'max_hp': char_data.get('max_hp', max_hp),
'current_mp': char_data.get('current_mp', max_mp),
'max_mp': char_data.get('max_mp', max_mp),
'stats': stats,
'equipped': char_data.get('equipped', {}),
'inventory': char_data.get('inventory', []),
'gold': char_data.get('gold', 0),
'experience': char_data.get('experience', 0)
}
# ===== Main Routes =====
@game_bp.route('/session/<session_id>')
@require_auth
def play_session(session_id: str):
"""
Production play screen for a game session.
Displays 3-column layout with character panel, narrative area,
and sidebar accordions for history/quests/NPCs/map.
"""
client = get_api_client()
try:
# Get session state (includes game_state with location info)
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
# Extract game state and build location info
game_state = session_data.get('game_state', {})
location = _build_location_from_game_state(game_state)
# Get character details - always build a valid character dict
character_id = session_data.get('character_id')
char_data = {}
if character_id:
try:
char_response = client.get(f'/api/v1/characters/{character_id}')
char_data = char_response.get('result', {})
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_character", character_id=character_id, error=str(e))
# Always build character with defaults for any missing fields
character = _build_character_from_api(char_data)
# Get session history (last DM response for display)
history = []
dm_response = "Your adventure awaits..."
try:
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=10')
history_data = history_response.get('result', {})
history = history_data.get('history', [])
# Get the most recent DM response for the main narrative panel
if history:
dm_response = history[0].get('dm_response', dm_response)
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_history", session_id=session_id, error=str(e))
# Get NPCs at current location
npcs = []
current_location_id = location.get('location_id')
if current_location_id:
try:
npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location_id}')
npcs = npcs_response.get('result', {}).get('npcs', [])
except (APINotFoundError, APIError) as e:
logger.debug("no_npcs_at_location", location_id=current_location_id, error=str(e))
# Get available travel destinations (discovered locations)
discovered_locations = []
try:
travel_response = client.get(f'/api/v1/travel/available?session_id={session_id}')
travel_result = travel_response.get('result', {})
discovered_locations = travel_result.get('available_locations', [])
# Mark current location
for loc in discovered_locations:
loc['is_current'] = loc.get('location_id') == current_location_id
except (APINotFoundError, APIError) as e:
logger.debug("failed_to_load_travel_destinations", session_id=session_id, error=str(e))
# Get quests (from character's active_quests or session)
quests = game_state.get('active_quests', [])
# If quests are just IDs, we could expand them, but for now use what we have
# Get user tier
user_tier = _get_user_tier(client)
# Build session object for template
session = {
'session_id': session_id,
'turn_number': session_data.get('turn_number', 0),
'status': session_data.get('status', 'active')
}
return render_template(
'game/play.html',
session_id=session_id,
session=session,
character=character,
location=location,
dm_response=dm_response,
history=history,
quests=quests,
npcs=npcs,
discovered_locations=discovered_locations,
actions=DEFAULT_ACTIONS,
user_tier=user_tier
)
except APINotFoundError:
logger.warning("session_not_found", session_id=session_id)
return render_template('errors/404.html', message=f"Session {session_id} not found"), 404
except APIError as e:
logger.error("failed_to_load_play_session", session_id=session_id, error=str(e))
return render_template('errors/500.html', message=str(e)), 500
# ===== Partial Refresh Routes =====
@game_bp.route('/session/<session_id>/character-panel')
@require_auth
def character_panel(session_id: str):
"""Refresh character stats and actions panel."""
client = get_api_client()
try:
# Get session to find character and location
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
location = _build_location_from_game_state(game_state)
# Get character - always build valid character dict
char_data = {}
character_id = session_data.get('character_id')
if character_id:
try:
char_response = client.get(f'/api/v1/characters/{character_id}')
char_data = char_response.get('result', {})
except (APINotFoundError, APIError):
pass
character = _build_character_from_api(char_data)
user_tier = _get_user_tier(client)
return render_template(
'game/partials/character_panel.html',
session_id=session_id,
character=character,
location=location,
actions=DEFAULT_ACTIONS,
user_tier=user_tier
)
except APIError as e:
logger.error("failed_to_refresh_character_panel", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load character panel: {e}</div>', 500
@game_bp.route('/session/<session_id>/narrative')
@require_auth
def narrative_panel(session_id: str):
"""Refresh narrative content panel."""
client = get_api_client()
try:
# Get session state
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
location = _build_location_from_game_state(game_state)
# Get latest DM response from history
dm_response = "Your adventure awaits..."
try:
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=1')
history_data = history_response.get('result', {})
history = history_data.get('history', [])
if history:
dm_response = history[0].get('dm_response', dm_response)
except (APINotFoundError, APIError):
pass
session = {
'session_id': session_id,
'turn_number': session_data.get('turn_number', 0),
'status': session_data.get('status', 'active')
}
return render_template(
'game/partials/narrative_panel.html',
session_id=session_id,
session=session,
location=location,
dm_response=dm_response
)
except APIError as e:
logger.error("failed_to_refresh_narrative", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load narrative: {e}</div>', 500
@game_bp.route('/session/<session_id>/history')
@require_auth
def history_accordion(session_id: str):
"""Refresh history accordion content."""
client = get_api_client()
try:
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=20')
history_data = history_response.get('result', {})
history = history_data.get('history', [])
return render_template(
'game/partials/sidebar_history.html',
session_id=session_id,
history=history
)
except APIError as e:
logger.error("failed_to_refresh_history", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load history: {e}</div>', 500
@game_bp.route('/session/<session_id>/quests')
@require_auth
def quests_accordion(session_id: str):
"""Refresh quests accordion content."""
client = get_api_client()
try:
# Get session to access game_state.active_quests
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
quests = game_state.get('active_quests', [])
return render_template(
'game/partials/sidebar_quests.html',
session_id=session_id,
quests=quests
)
except APIError as e:
logger.error("failed_to_refresh_quests", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load quests: {e}</div>', 500
@game_bp.route('/session/<session_id>/npcs')
@require_auth
def npcs_accordion(session_id: str):
"""Refresh NPCs accordion content."""
client = get_api_client()
try:
# Get session to find current location
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
current_location_id = game_state.get('current_location_id') or game_state.get('current_location')
# Get NPCs at location
npcs = []
if current_location_id:
try:
npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location_id}')
npcs = npcs_response.get('result', {}).get('npcs', [])
except (APINotFoundError, APIError):
pass
return render_template(
'game/partials/sidebar_npcs.html',
session_id=session_id,
npcs=npcs
)
except APIError as e:
logger.error("failed_to_refresh_npcs", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load NPCs: {e}</div>', 500
@game_bp.route('/session/<session_id>/map')
@require_auth
def map_accordion(session_id: str):
"""Refresh map accordion content."""
client = get_api_client()
try:
# Get session for current location
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
current_location = _build_location_from_game_state(game_state)
current_location_id = current_location.get('location_id')
# Get available travel destinations
discovered_locations = []
try:
travel_response = client.get(f'/api/v1/travel/available?session_id={session_id}')
travel_result = travel_response.get('result', {})
discovered_locations = travel_result.get('available_locations', [])
# Mark current location
for loc in discovered_locations:
loc['is_current'] = loc.get('location_id') == current_location_id
except (APINotFoundError, APIError):
pass
return render_template(
'game/partials/sidebar_map.html',
session_id=session_id,
discovered_locations=discovered_locations,
current_location=current_location
)
except APIError as e:
logger.error("failed_to_refresh_map", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load map: {e}</div>', 500
# ===== Action Routes =====
@game_bp.route('/session/<session_id>/action', methods=['POST'])
@require_auth
def take_action(session_id: str):
"""
Submit an action - returns job polling partial.
Handles two action types:
- 'button': Predefined action via prompt_id
- 'custom': Free-form player text input
"""
client = get_api_client()
action_type = request.form.get('action_type', 'button')
try:
# Build payload based on action type
payload = {'action_type': action_type}
if action_type == 'text' or action_type == 'custom':
# Free-form text action from player input
action_text = request.form.get('action_text', request.form.get('custom_text', '')).strip()
if not action_text:
return '<div class="dm-response error">Please enter an action.</div>', 400
logger.info("Player text action submitted",
session_id=session_id,
action_text=action_text[:100])
payload['action_type'] = 'custom'
payload['custom_text'] = action_text
player_action = action_text
else:
# Button action via prompt_id
prompt_id = request.form.get('prompt_id')
if not prompt_id:
return '<div class="dm-response error">No action selected.</div>', 400
logger.info("Player button action submitted",
session_id=session_id,
prompt_id=prompt_id)
payload['prompt_id'] = prompt_id
player_action = None # Will display prompt_id display text
# POST to API
response = client.post(f'/api/v1/sessions/{session_id}/action', payload)
result = response.get('result', {})
job_id = result.get('job_id')
if not job_id:
# Immediate response (shouldn't happen, but handle it)
dm_response = result.get('dm_response', 'Action completed.')
return render_template(
'game/partials/dm_response.html',
session_id=session_id,
dm_response=dm_response
)
# Return polling partial
return render_template(
'game/partials/job_polling.html',
session_id=session_id,
job_id=job_id,
status=result.get('status', 'queued'),
player_action=player_action
)
except APIError as e:
logger.error("failed_to_take_action", session_id=session_id, error=str(e))
return f'<div class="dm-response error">Action failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/job/<job_id>')
@require_auth
def poll_job(session_id: str, job_id: str):
"""Poll job status - returns updated partial."""
client = get_api_client()
try:
response = client.get(f'/api/v1/jobs/{job_id}/status')
result = response.get('result', {})
status = result.get('status', 'unknown')
if status == 'completed':
# Job done - check for NPC dialogue vs story action
nested_result = result.get('result', {})
if nested_result.get('context_type') == 'npc_dialogue':
# NPC dialogue response - return dialogue partial
return render_template(
'game/partials/npc_dialogue_response.html',
npc_name=nested_result.get('npc_name', 'NPC'),
character_name=nested_result.get('character_name', 'You'),
conversation_history=nested_result.get('conversation_history', []),
player_line=nested_result.get('player_line', ''),
dialogue=nested_result.get('dialogue', 'No response'),
session_id=session_id
)
else:
# Standard DM response
dm_response = result.get('dm_response', nested_result.get('dm_response', 'No response'))
return render_template(
'game/partials/dm_response.html',
session_id=session_id,
dm_response=dm_response
)
elif status in ('failed', 'error'):
error_msg = result.get('error', 'Unknown error occurred')
return f'<div class="dm-response error">Action failed: {error_msg}</div>'
else:
# Still processing - return polling partial to continue
return render_template(
'game/partials/job_polling.html',
session_id=session_id,
job_id=job_id,
status=status
)
except APIError as e:
logger.error("failed_to_poll_job", job_id=job_id, session_id=session_id, error=str(e))
return f'<div class="dm-response error">Failed to check job status: {e}</div>', 500
# ===== Modal Routes =====
@game_bp.route('/session/<session_id>/equipment-modal')
@require_auth
def equipment_modal(session_id: str):
"""Get equipment modal with character's gear."""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
character = {}
if character_id:
char_response = client.get(f'/api/v1/characters/{character_id}')
char_data = char_response.get('result', {})
character = _build_character_from_api(char_data)
return render_template(
'game/partials/equipment_modal.html',
session_id=session_id,
character=character
)
except APIError as e:
logger.error("failed_to_load_equipment_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Equipment</h3>
<div class="error">Failed to load equipment: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/travel-modal')
@require_auth
def travel_modal(session_id: str):
"""Get travel modal with available destinations."""
client = get_api_client()
try:
# Get available travel destinations
response = client.get(f'/api/v1/travel/available?session_id={session_id}')
result = response.get('result', {})
available_locations = result.get('available_locations', [])
current_location_id = result.get('current_location')
# Get current location details from session
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
current_location = _build_location_from_game_state(game_state)
# Filter out current location from destinations
destinations = [loc for loc in available_locations if loc.get('location_id') != current_location_id]
return render_template(
'game/partials/travel_modal.html',
session_id=session_id,
destinations=destinations,
current_location=current_location
)
except APIError as e:
logger.error("failed_to_load_travel_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Travel</h3>
<div class="error">Failed to load travel options: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/travel', methods=['POST'])
@require_auth
def do_travel(session_id: str):
"""Execute travel to location - returns job polling partial or immediate response."""
client = get_api_client()
location_id = request.form.get('location_id')
if not location_id:
return '<div class="error">No destination selected.</div>', 400
try:
response = client.post('/api/v1/travel', {
'session_id': session_id,
'location_id': location_id
})
result = response.get('result', {})
# Check if travel triggers a job (narrative generation)
job_id = result.get('job_id')
if job_id:
# Close modal and return job polling partial
return f'''
<script>document.querySelector('.modal-overlay')?.remove();</script>
''' + render_template(
'game/partials/job_polling.html',
job_id=job_id,
session_id=session_id,
status='queued'
)
# Immediate response (no AI generation)
narrative = result.get('narrative', result.get('description', 'You arrive at your destination.'))
location_name = result.get('location_name', 'Unknown Location')
# Close modal and update response area
return f'''
<script>document.querySelector('.modal-overlay')?.remove();</script>
<div class="dm-response">
<strong>Arrived at {location_name}</strong><br><br>
{narrative}
</div>
''' + render_template(
'game/partials/dm_response.html',
session_id=session_id,
dm_response=f"**Arrived at {location_name}**\n\n{narrative}"
)
except APIError as e:
logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e))
return f'<div class="error">Travel failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>/chat')
@require_auth
def npc_chat_modal(session_id: str, npc_id: str):
"""Get NPC chat modal with conversation history."""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
# Get NPC details with relationship info
npc_response = client.get(f'/api/v1/npcs/{npc_id}?character_id={character_id}')
npc_data = npc_response.get('result', {})
npc = {
'npc_id': npc_data.get('npc_id'),
'name': npc_data.get('name'),
'role': npc_data.get('role'),
'appearance': npc_data.get('appearance', {}).get('brief', ''),
'tags': npc_data.get('tags', [])
}
# Get relationship info
interaction_summary = npc_data.get('interaction_summary', {})
relationship_level = interaction_summary.get('relationship_level', 50)
interaction_count = interaction_summary.get('interaction_count', 0)
# Conversation history would come from character's npc_interactions
# For now, we'll leave it empty - the API returns it in dialogue responses
conversation_history = []
return render_template(
'game/partials/npc_chat_modal.html',
session_id=session_id,
npc=npc,
conversation_history=conversation_history,
relationship_level=relationship_level,
interaction_count=interaction_count
)
except APINotFoundError:
return '<div class="error">NPC not found</div>', 404
except APIError as e:
logger.error("failed_to_load_npc_chat", session_id=session_id, npc_id=npc_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Talk to NPC</h3>
<div class="error">Failed to load NPC info: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST'])
@require_auth
def talk_to_npc(session_id: str, npc_id: str):
"""Send message to NPC - returns dialogue response or job polling partial."""
client = get_api_client()
# Support both topic (initial greeting) and player_response (conversation)
player_response = request.form.get('player_response')
topic = request.form.get('topic', 'greeting')
try:
payload = {'session_id': session_id}
if player_response:
# Player typed a custom response
payload['player_response'] = player_response
else:
# Initial greeting click
payload['topic'] = topic
response = client.post(f'/api/v1/npcs/{npc_id}/talk', payload)
result = response.get('result', {})
# Check if it's a job-based response (async) or immediate
job_id = result.get('job_id')
if job_id:
# Return job polling partial for the chat area
return render_template(
'game/partials/job_polling.html',
job_id=job_id,
session_id=session_id,
status='queued',
is_npc_dialogue=True
)
# Immediate response (if AI is sync or cached)
dialogue = result.get('dialogue', result.get('response', 'The NPC has nothing to say.'))
npc_name = result.get('npc_name', 'NPC')
# Return dialogue in chat format
player_display = player_response if player_response else f"[{topic}]"
return f'''
<div class="chat-message chat-message--player">
<strong>You:</strong> {player_display}
</div>
<div class="chat-message chat-message--npc">
<strong>{npc_name}:</strong> {dialogue}
</div>
'''
except APINotFoundError:
return '<div class="error">NPC not found.</div>', 404
except APIError as e:
logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e))
return f'<div class="error">Failed to talk to NPC: {e}</div>', 500

View File

@@ -0,0 +1,46 @@
# Development Configuration for Public Web Frontend
app:
name: "Code of Conquest - Web UI"
version: "0.1.0"
environment: "development"
debug: true
server:
host: "0.0.0.0"
port: 8000 # Different port from API (5000)
debug: true
workers: 1
api:
# API backend base URL
base_url: "http://localhost:5000"
timeout: 30
verify_ssl: false # Set to true in production
session:
# Session lifetime in hours
lifetime_hours: 24
cookie_secure: false # Set to true in production (HTTPS only)
cookie_httponly: true
cookie_samesite: "Lax"
cors:
enabled: true
origins:
- "http://localhost:8000"
- "http://127.0.0.1:8000"
logging:
level: "DEBUG"
format: "json"
handlers:
- "console"
- "file"
file_path: "logs/app.log"
# UI Settings
ui:
theme: "dark"
items_per_page: 20
enable_animations: true

View File

@@ -0,0 +1,43 @@
# Production Configuration for Public Web Frontend
app:
name: "Code of Conquest - Web UI"
version: "0.1.0"
environment: "production"
debug: false
server:
host: "0.0.0.0"
port: 8000
workers: 4
api:
# API backend base URL (set via environment variable in production)
# Use: API_BASE_URL environment variable
base_url: "https://api.codeofconquest.com"
timeout: 30
verify_ssl: true
session:
# Session lifetime in hours
lifetime_hours: 24
cookie_secure: true # HTTPS only
cookie_httponly: true
cookie_samesite: "Strict"
cors:
enabled: false # Not needed in production if same domain
logging:
level: "INFO"
format: "json"
handlers:
- "console"
- "file"
file_path: "/var/log/coc/web.log"
# UI Settings
ui:
theme: "dark"
items_per_page: 20
enable_animations: true

0
public_web/docs/.gitkeep Normal file
View File

View File

@@ -0,0 +1,651 @@
# HTMX Integration Patterns - Public Web Frontend
**Last Updated:** November 18, 2025
---
## Overview
This document outlines HTMX usage patterns, best practices, and common implementations for the Code of Conquest web frontend.
**HTMX Benefits:**
- Server-side rendering with dynamic interactions
- No JavaScript framework overhead
- Progressive enhancement (works without JS)
- Clean separation of concerns
- Natural integration with Flask/Jinja2
---
## Core HTMX Attributes
### hx-get / hx-post / hx-put / hx-delete
Make HTTP requests from HTML elements:
```html
<!-- GET request -->
<button hx-get="/api/v1/characters" hx-target="#characters">
Load Characters
</button>
<!-- POST request -->
<form hx-post="/api/v1/characters" hx-target="#result">
<!-- form fields -->
<button type="submit">Create</button>
</form>
<!-- DELETE request -->
<button hx-delete="/api/v1/characters/{{ character_id }}"
hx-target="closest .character-card"
hx-swap="outerHTML">
Delete
</button>
```
### hx-target
Specify where to insert the response:
```html
<!-- CSS selector -->
<button hx-get="/content" hx-target="#content-div">Load</button>
<!-- Relative selectors -->
<button hx-delete="/item" hx-target="closest .item">Delete</button>
<button hx-post="/append" hx-target="next .list">Add</button>
<!-- Special values -->
<button hx-get="/modal" hx-target="body">Show Modal</button>
```
### hx-swap
Control how content is swapped:
```html
<!-- innerHTML (default) -->
<div hx-get="/content" hx-target="#div" hx-swap="innerHTML"></div>
<!-- outerHTML (replace element itself) -->
<div hx-get="/content" hx-target="#div" hx-swap="outerHTML"></div>
<!-- beforebegin (before target) -->
<div hx-get="/content" hx-target="#div" hx-swap="beforebegin"></div>
<!-- afterbegin (first child) -->
<div hx-get="/content" hx-target="#div" hx-swap="afterbegin"></div>
<!-- beforeend (last child) -->
<div hx-get="/content" hx-target="#div" hx-swap="beforeend"></div>
<!-- afterend (after target) -->
<div hx-get="/content" hx-target="#div" hx-swap="afterend"></div>
<!-- none (no swap, just trigger) -->
<div hx-get="/trigger" hx-swap="none"></div>
```
### hx-trigger
Specify what triggers the request:
```html
<!-- Click (default for buttons) -->
<button hx-get="/content">Click Me</button>
<!-- Change (default for inputs) -->
<select hx-get="/filter" hx-trigger="change">...</select>
<!-- Custom events -->
<div hx-get="/content" hx-trigger="customEvent">...</div>
<!-- Multiple triggers -->
<div hx-get="/content" hx-trigger="mouseenter, focus">...</div>
<!-- Trigger modifiers -->
<input hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results">
<!-- Polling -->
<div hx-get="/status" hx-trigger="every 5s">Status: ...</div>
<!-- Load once -->
<div hx-get="/content" hx-trigger="load">...</div>
```
---
## Common Patterns
### Form Submission
**Pattern:** Submit form via AJAX, replace form with result
```html
<form hx-post="/api/v1/characters"
hx-target="#form-container"
hx-swap="outerHTML">
<input type="text" name="name" placeholder="Character Name" required>
<select name="class">
<option value="vanguard">Vanguard</option>
<option value="luminary">Luminary</option>
</select>
<button type="submit">Create Character</button>
</form>
```
**Backend Response:**
```python
@characters_bp.route('/', methods=['POST'])
def create_character():
# Create character via API
response = api_client.post('/characters', request.form)
if response.status_code == 200:
character = response.json()['result']
return render_template('partials/character_card.html', character=character)
else:
return render_template('partials/form_error.html', error=response.json()['error'])
```
### Delete with Confirmation
**Pattern:** Confirm before deleting, remove element on success
```html
<div class="character-card" id="char-{{ character.character_id }}">
<h3>{{ character.name }}</h3>
<button hx-delete="/api/v1/characters/{{ character.character_id }}"
hx-confirm="Are you sure you want to delete {{ character.name }}?"
hx-target="closest .character-card"
hx-swap="outerHTML swap:1s">
Delete
</button>
</div>
```
**Backend Response (empty for delete):**
```python
@characters_bp.route('/<character_id>', methods=['DELETE'])
def delete_character(character_id):
api_client.delete(f'/characters/{character_id}')
return '', 200 # Empty response removes element
```
### Search/Filter
**Pattern:** Live search with debouncing
```html
<input type="text"
name="search"
placeholder="Search characters..."
hx-get="/characters/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#character-list"
hx-indicator="#search-spinner">
<span id="search-spinner" class="htmx-indicator">Searching...</span>
<div id="character-list">
<!-- Results appear here -->
</div>
```
**Backend:**
```python
@characters_bp.route('/search')
def search_characters():
query = request.args.get('search', '')
characters = api_client.get(f'/characters?search={query}')
return render_template('partials/character_list.html', characters=characters)
```
### Pagination
**Pattern:** Load more items
```html
<div id="character-list">
{% for character in characters %}
{% include 'partials/character_card.html' %}
{% endfor %}
</div>
{% if has_more %}
<button hx-get="/characters?page={{ page + 1 }}"
hx-target="#character-list"
hx-swap="beforeend"
hx-select=".character-card">
Load More
</button>
{% endif %}
```
### Inline Edit
**Pattern:** Click to edit, save inline
```html
<div class="character-name" id="name-{{ character.character_id }}">
<span hx-get="/characters/{{ character.character_id }}/edit-name"
hx-target="closest div"
hx-swap="outerHTML">
{{ character.name }} ✏️
</span>
</div>
```
**Edit Form Response:**
```html
<div class="character-name" id="name-{{ character.character_id }}">
<form hx-put="/characters/{{ character.character_id }}/name"
hx-target="closest div"
hx-swap="outerHTML">
<input type="text" name="name" value="{{ character.name }}" autofocus>
<button type="submit">Save</button>
<button type="button" hx-get="/characters/{{ character.character_id }}/name" hx-target="closest div" hx-swap="outerHTML">Cancel</button>
</form>
</div>
```
### Modal Dialog
**Pattern:** Load modal content dynamically
```html
<button hx-get="/characters/{{ character.character_id }}/details"
hx-target="#modal-container"
hx-swap="innerHTML">
View Details
</button>
<div id="modal-container"></div>
```
**Modal Response:**
```html
<div class="modal" id="character-modal">
<div class="modal-content">
<span class="close" onclick="closeModal()">&times;</span>
<h2>{{ character.name }}</h2>
<!-- character details -->
</div>
</div>
<script>
function closeModal() {
document.getElementById('character-modal').remove();
}
</script>
```
### Tabs
**Pattern:** Tab switching without page reload
```html
<div class="tabs">
<button hx-get="/dashboard/overview"
hx-target="#tab-content"
class="tab active">
Overview
</button>
<button hx-get="/dashboard/characters"
hx-target="#tab-content"
class="tab">
Characters
</button>
<button hx-get="/dashboard/sessions"
hx-target="#tab-content"
class="tab">
Sessions
</button>
</div>
<div id="tab-content">
<!-- Tab content loads here -->
</div>
```
### Polling for Updates
**Pattern:** Auto-refresh session status
```html
<div hx-get="/sessions/{{ session_id }}/status"
hx-trigger="every 5s"
hx-target="#session-status">
Loading status...
</div>
```
**Conditional Polling (stop when complete):**
```html
<div hx-get="/sessions/{{ session_id }}/status"
hx-trigger="every 5s[status !== 'completed']"
hx-target="#session-status">
Status: {{ session.status }}
</div>
```
### Infinite Scroll
**Pattern:** Load more as user scrolls
```html
<div id="character-list">
{% for character in characters %}
{% include 'partials/character_card.html' %}
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/characters?page={{ page + 1 }}"
hx-trigger="revealed"
hx-target="#character-list"
hx-swap="beforeend">
Loading more...
</div>
{% endif %}
```
---
## Advanced Patterns
### Dependent Dropdowns
**Pattern:** Update second dropdown based on first selection
```html
<select name="class"
hx-get="/characters/abilities"
hx-target="#ability-select"
hx-trigger="change">
<option value="vanguard">Vanguard</option>
<option value="luminary">Luminary</option>
</select>
<div id="ability-select">
<!-- Abilities dropdown loads here based on class -->
</div>
```
### Out of Band Swaps
**Pattern:** Update multiple areas from single response
```html
<button hx-post="/combat/attack"
hx-target="#combat-log"
hx-swap="beforeend">
Attack
</button>
<div id="combat-log"><!-- Combat log --></div>
<div id="character-hp">HP: 50/100</div>
```
**Backend Response:**
```html
<!-- Primary swap target -->
<div>You dealt 15 damage!</div>
<!-- Out of band swap -->
<div id="character-hp" hx-swap-oob="true">HP: 45/100</div>
```
### Optimistic UI
**Pattern:** Show result immediately, revert on error
```html
<button hx-post="/characters/{{ character_id }}/favorite"
hx-target="#favorite-btn"
hx-swap="outerHTML"
class="btn">
⭐ Favorite
</button>
```
**Immediate feedback with CSS:**
```css
.htmx-request .htmx-indicator {
display: inline;
}
```
### Progressive Enhancement
**Pattern:** Fallback to normal form submission
```html
<form action="/characters" method="POST"
hx-post="/characters"
hx-target="#result">
<!-- If HTMX fails, form still works via normal POST -->
<input type="text" name="name">
<button type="submit">Create</button>
</form>
```
---
## HTMX + Appwrite Realtime
### Hybrid Approach
Use HTMX for user actions, Appwrite Realtime for server push updates:
```html
<!-- HTMX for user actions -->
<button hx-post="/sessions/{{ session_id }}/ready"
hx-target="#lobby-status">
Ready
</button>
<!-- Appwrite Realtime for live updates -->
<script>
const client = new Appwrite.Client()
.setEndpoint('{{ config.appwrite_endpoint }}')
.setProject('{{ config.appwrite_project_id }}');
client.subscribe('channel', response => {
// Trigger HTMX to reload specific section
htmx.ajax('GET', '/sessions/{{ session_id }}/status', {
target: '#session-status',
swap: 'innerHTML'
});
});
</script>
```
---
## Error Handling
### Show Error Messages
```html
<form hx-post="/characters"
hx-target="#form-container"
hx-target-error="#error-container">
<!-- form fields -->
</form>
<div id="error-container"></div>
```
**Backend Error Response:**
```python
@characters_bp.route('/', methods=['POST'])
def create_character():
try:
# Create character
pass
except ValidationError as e:
response = make_response(render_template('partials/error.html', error=str(e)), 400)
response.headers['HX-Retarget'] = '#error-container'
return response
```
### Retry on Failure
```html
<div hx-get="/api/v1/data"
hx-trigger="load, error from:body delay:5s">
Loading...
</div>
```
---
## Loading Indicators
### Global Indicator
```html
<style>
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
</style>
<button hx-post="/action" hx-indicator="#spinner">
Submit
</button>
<span id="spinner" class="htmx-indicator">Loading...</span>
```
### Inline Indicator
```html
<button hx-get="/content">
<span class="button-text">Load</span>
<span class="htmx-indicator">Loading...</span>
</button>
```
---
## Best Practices
### 1. Use Semantic HTML
```html
<!-- Good -->
<button hx-post="/action">Submit</button>
<!-- Avoid -->
<div hx-post="/action">Submit</div>
```
### 2. Provide Fallbacks
```html
<form action="/submit" method="POST" hx-post="/submit">
<!-- Works without JavaScript -->
</form>
```
### 3. Use hx-indicator for Loading States
```html
<button hx-post="/action" hx-indicator="#spinner">
Submit
</button>
<span id="spinner" class="htmx-indicator"></span>
```
### 4. Debounce Search Inputs
```html
<input hx-get="/search"
hx-trigger="keyup changed delay:500ms">
```
### 5. Use CSRF Protection
```html
<form hx-post="/action">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
```
---
## Debugging
### HTMX Events
Listen to HTMX events for debugging:
```html
<script>
document.body.addEventListener('htmx:beforeRequest', (event) => {
console.log('Before request:', event.detail);
});
document.body.addEventListener('htmx:afterRequest', (event) => {
console.log('After request:', event.detail);
});
document.body.addEventListener('htmx:responseError', (event) => {
console.error('Response error:', event.detail);
});
</script>
```
### HTMX Logger Extension
```html
<script src="https://unpkg.com/htmx.org/dist/ext/debug.js"></script>
<body hx-ext="debug">
```
---
## Performance Tips
### 1. Use hx-select to Extract Partial
```html
<button hx-get="/full-page"
hx-select="#content-section"
hx-target="#result">
Load Section
</button>
```
### 2. Disable During Request
```html
<button hx-post="/action" hx-disable-during-request>
Submit
</button>
```
### 3. Use hx-sync for Sequential Requests
```html
<div hx-sync="this:replace">
<button hx-get="/content">Load</button>
</div>
```
---
## Related Documentation
- **[TEMPLATES.md](TEMPLATES.md)** - Template structure and conventions
- **[TESTING.md](TESTING.md)** - Manual testing guide
- **[/api/docs/API_REFERENCE.md](../../api/docs/API_REFERENCE.md)** - API endpoints
- **[HTMX Official Docs](https://htmx.org/docs/)** - Complete HTMX documentation
---
**Document Version:** 1.1
**Created:** November 18, 2025
**Last Updated:** November 21, 2025

View File

@@ -0,0 +1,738 @@
# Multiplayer System - Web Frontend
**Status:** Planned
**Phase:** 6 (Multiplayer Sessions)
**Last Updated:** November 18, 2025
---
## Overview
The Web Frontend handles the UI/UX for multiplayer sessions, including lobby screens, active session displays, combat interfaces, and realtime synchronization via JavaScript/HTMX patterns.
**Frontend Responsibilities:**
- Render multiplayer session creation forms
- Display lobby with player list and ready status
- Show active session UI (timer, party status, combat)
- Handle realtime updates via Appwrite Realtime WebSocket
- Submit player actions to API backend
- Display session completion and rewards
**Technical Stack:**
- **Templates**: Jinja2
- **Interactivity**: HTMX for AJAX interactions
- **Realtime**: Appwrite JavaScript SDK for WebSocket subscriptions
- **Styling**: Custom CSS (responsive design)
---
## UI/UX Design
### Session Creation Screen (Host)
**Route:** `/multiplayer/create`
**Template:** `templates/multiplayer/create.html`
```html
{% extends "base.html" %}
{% block content %}
<div class="multiplayer-create">
<h1>Create Multiplayer Session <span class="badge-premium">Premium</span></h1>
<p>Invite your friends to a 2-hour co-op adventure!</p>
<form hx-post="/api/v1/sessions/multiplayer/create"
hx-target="#session-result"
hx-swap="innerHTML">
<div class="form-group">
<label>Party Size:</label>
<div class="radio-group">
<input type="radio" name="max_players" value="2" id="size-2">
<label for="size-2">2 Players</label>
<input type="radio" name="max_players" value="3" id="size-3">
<label for="size-3">3 Players</label>
<input type="radio" name="max_players" value="4" id="size-4" checked>
<label for="size-4">4 Players</label>
</div>
</div>
<div class="form-group">
<label>Difficulty:</label>
<div class="radio-group">
<input type="radio" name="difficulty" value="easy" id="diff-easy">
<label for="diff-easy">Easy</label>
<input type="radio" name="difficulty" value="medium" id="diff-medium" checked>
<label for="diff-medium">Medium</label>
<input type="radio" name="difficulty" value="hard" id="diff-hard">
<label for="diff-hard">Hard</label>
<input type="radio" name="difficulty" value="deadly" id="diff-deadly">
<label for="diff-deadly">Deadly</label>
</div>
</div>
<p class="info-text">
AI will generate a custom campaign for your party based on your
characters' levels and the selected difficulty.
</p>
<button type="submit" class="btn btn-primary">Create Session</button>
</form>
<div id="session-result"></div>
</div>
{% endblock %}
```
**HTMX Pattern:**
- Form submission via `hx-post` to API endpoint
- Response replaces `#session-result` div
- API returns session details and redirects to lobby
### Lobby Screen
**Route:** `/multiplayer/lobby/{session_id}`
**Template:** `templates/multiplayer/lobby.html`
```html
{% extends "base.html" %}
{% block content %}
<div class="multiplayer-lobby">
<h1>Multiplayer Lobby
<button class="btn btn-secondary"
hx-post="/api/v1/sessions/multiplayer/{{ session.session_id }}/leave"
hx-confirm="Are you sure you want to leave?">
Leave Lobby
</button>
</h1>
<div class="invite-section">
<p><strong>Session Code:</strong> {{ session.invite_code }}</p>
<p>
<strong>Invite Link:</strong>
<input type="text"
id="invite-link"
value="https://codeofconquest.com/join/{{ session.invite_code }}"
readonly>
<button onclick="copyInviteLink()" class="btn btn-sm">Copy Link</button>
</p>
</div>
<div class="lobby-info">
<p><strong>Difficulty:</strong> {{ session.campaign.difficulty|title }}</p>
<p><strong>Duration:</strong> 2 hours</p>
</div>
<div id="party-list" class="party-list">
<!-- Dynamically updated via realtime -->
{% for member in session.party_members %}
<div class="party-member" data-user-id="{{ member.user_id }}">
{% if member.is_host %}
<span class="crown">👑</span>
{% endif %}
<span class="username">{{ member.username }} {% if member.is_host %}(Host){% endif %}</span>
<span class="character">{{ member.character_snapshot.name }} - {{ member.character_snapshot.player_class.name }} Lvl {{ member.character_snapshot.level }}</span>
<span class="ready-status">
{% if member.is_ready %}
✅ Ready
{% else %}
⏳ Not Ready
{% endif %}
</span>
</div>
{% endfor %}
<!-- Empty slots -->
{% for i in range(session.max_players - session.party_members|length) %}
<div class="party-member empty">
<span>[Waiting for player...]</span>
</div>
{% endfor %}
</div>
<div class="lobby-actions">
{% if current_user.user_id == session.host_user_id %}
<!-- Host controls -->
<button id="start-session-btn"
class="btn btn-primary"
hx-post="/api/v1/sessions/multiplayer/{{ session.session_id }}/start"
hx-target="#lobby-status"
{% if not all_ready %}disabled{% endif %}>
Start Session
</button>
{% else %}
<!-- Player ready toggle -->
<button class="btn btn-primary"
hx-post="/api/v1/sessions/multiplayer/{{ session.session_id }}/ready"
hx-target="#lobby-status">
{% if current_member.is_ready %}Not Ready{% else %}Ready{% endif %}
</button>
{% endif %}
</div>
<div id="lobby-status"></div>
</div>
<script>
// Realtime subscription for lobby updates
const { Client, Databases } = Appwrite;
const client = new Client()
.setEndpoint('{{ appwrite_endpoint }}')
.setProject('{{ appwrite_project_id }}');
const databases = new Databases(client);
// Subscribe to session updates
client.subscribe(`databases.{{ db_id }}.collections.multiplayer_sessions.documents.{{ session.session_id }}`, response => {
console.log('Session updated:', response);
// Update party list
updatePartyList(response.payload);
// Check if all ready, enable/disable start button
checkAllReady(response.payload);
// If session started, redirect to active session
if (response.payload.status === 'active') {
window.location.href = '/multiplayer/session/{{ session.session_id }}';
}
});
function updatePartyList(sessionData) {
// Update party member ready status dynamically
sessionData.party_members.forEach(member => {
const memberEl = document.querySelector(`.party-member[data-user-id="${member.user_id}"]`);
if (memberEl) {
const statusEl = memberEl.querySelector('.ready-status');
statusEl.innerHTML = member.is_ready ? '✅ Ready' : '⏳ Not Ready';
}
});
}
function checkAllReady(sessionData) {
const allReady = sessionData.party_members.every(m => m.is_ready);
const startBtn = document.getElementById('start-session-btn');
if (startBtn) {
startBtn.disabled = !allReady;
}
}
function copyInviteLink() {
const linkInput = document.getElementById('invite-link');
linkInput.select();
document.execCommand('copy');
alert('Invite link copied to clipboard!');
}
</script>
{% endblock %}
```
**Realtime Pattern:**
- JavaScript subscribes to Appwrite Realtime for session updates
- Updates player ready status dynamically
- Redirects to active session when host starts
- No page reload required
### Active Session Screen
**Route:** `/multiplayer/session/{session_id}`
**Template:** `templates/multiplayer/session.html`
```html
{% extends "base.html" %}
{% block content %}
<div class="multiplayer-session">
<div class="session-header">
<h1>{{ session.campaign.title }}</h1>
<div class="timer" id="session-timer">
⏱️ <span id="time-remaining">{{ time_remaining }}</span> Remaining
</div>
</div>
<div class="campaign-progress">
<p>Campaign Progress:
<span class="progress-bar">
<span class="progress-fill" style="width: {{ (session.current_encounter_index / session.campaign.encounters|length) * 100 }}%"></span>
</span>
({{ session.current_encounter_index }}/{{ session.campaign.encounters|length }} encounters)
</p>
</div>
<div class="party-status">
<h3>Party:</h3>
<div class="party-members" id="party-status">
{% for member in session.party_members %}
<div class="party-member-status">
<span class="name">{{ member.character_snapshot.name }}</span>
<span class="hp">HP: <span id="hp-{{ member.character_id }}">{{ member.character_snapshot.current_hp }}</span>/{{ member.character_snapshot.max_hp }}</span>
{% if not member.is_connected %}
<span class="disconnected">⚠️ Disconnected</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<div class="narrative-panel" id="narrative-panel">
<h3>Narrative:</h3>
<div class="conversation-history">
{% for entry in session.conversation_history %}
<div class="conversation-entry {{ entry.role }}">
<strong>{{ entry.role|title }}:</strong> {{ entry.content }}
</div>
{% endfor %}
</div>
</div>
{% if session.combat_encounter %}
<!-- Combat UI -->
<div class="combat-section" id="combat-section">
<h3>Combat: {{ current_encounter.title }}</h3>
<div class="turn-order" id="turn-order">
<h4>Turn Order:</h4>
<ol>
{% for combatant_id in session.combat_encounter.turn_order %}
<li class="{% if loop.index0 == session.combat_encounter.current_turn_index %}current-turn{% endif %}">
{{ get_combatant_name(combatant_id) }}
{% if is_current_user_turn(combatant_id) %}
<strong>(Your Turn!)</strong>
{% endif %}
</li>
{% endfor %}
</ol>
</div>
{% if is_current_user_turn() %}
<!-- Combat action form (only visible on player's turn) -->
<div class="combat-actions">
<h4>Your Action:</h4>
<form hx-post="/api/v1/sessions/multiplayer/{{ session.session_id }}/combat/action"
hx-target="#combat-result"
hx-swap="innerHTML">
<button type="submit" name="action_type" value="attack" class="btn btn-action">Attack</button>
<select name="ability_id" class="ability-select">
<option value="">Use Ability ▼</option>
{% for ability in current_character.abilities %}
<option value="{{ ability.ability_id }}">{{ ability.name }}</option>
{% endfor %}
</select>
<select name="item_id" class="item-select">
<option value="">Use Item ▼</option>
{% for item in current_character.inventory %}
<option value="{{ item.item_id }}">{{ item.name }}</option>
{% endfor %}
</select>
<button type="submit" name="action_type" value="defend" class="btn btn-action">Defend</button>
</form>
<div id="combat-result"></div>
</div>
{% else %}
<p class="waiting-turn">Waiting for other players...</p>
{% endif %}
</div>
{% endif %}
</div>
<script>
// Realtime subscription for combat and session updates
const client = new Client()
.setEndpoint('{{ appwrite_endpoint }}')
.setProject('{{ appwrite_project_id }}');
// Subscribe to session updates
client.subscribe(`databases.{{ db_id }}.collections.multiplayer_sessions.documents.{{ session.session_id }}`, response => {
console.log('Session updated:', response);
// Update timer
updateTimer(response.payload.time_remaining_seconds);
// Update party status
updatePartyStatus(response.payload.party_members);
// Update combat state
if (response.payload.combat_encounter) {
updateCombatUI(response.payload.combat_encounter);
}
// Check for session expiration
if (response.payload.status === 'expired' || response.payload.status === 'completed') {
window.location.href = `/multiplayer/complete/{{ session.session_id }}`;
}
});
// Timer countdown
let timeRemaining = {{ session.time_remaining_seconds }};
setInterval(() => {
if (timeRemaining > 0) {
timeRemaining--;
updateTimerDisplay(timeRemaining);
}
}, 1000);
function updateTimerDisplay(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const display = `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
document.getElementById('time-remaining').innerText = display;
// Warnings
if (seconds === 600) alert('⚠️ 10 minutes remaining!');
if (seconds === 300) alert('⚠️ 5 minutes remaining!');
if (seconds === 60) alert('🚨 1 minute remaining!');
}
function updateTimer(seconds) {
timeRemaining = seconds;
}
function updatePartyStatus(partyMembers) {
partyMembers.forEach(member => {
const hpEl = document.getElementById(`hp-${member.character_id}`);
if (hpEl) {
hpEl.innerText = member.character_snapshot.current_hp;
}
});
}
function updateCombatUI(combat) {
// Update turn order highlighting
const turnItems = document.querySelectorAll('.turn-order li');
turnItems.forEach((item, index) => {
item.classList.toggle('current-turn', index === combat.current_turn_index);
});
// Reload page section if turn changed (HTMX can handle partial updates)
htmx.ajax('GET', `/multiplayer/session/{{ session.session_id }}/combat-ui`, {target: '#combat-section', swap: 'outerHTML'});
}
</script>
{% endblock %}
```
**Realtime Pattern:**
- Subscribe to session document changes
- Update timer countdown locally
- Update party HP dynamically
- Reload combat UI section when turn changes
- Redirect to completion screen when session ends
### Session Complete Screen
**Route:** `/multiplayer/complete/{session_id}`
**Template:** `templates/multiplayer/complete.html`
```html
{% extends "base.html" %}
{% block content %}
<div class="session-complete">
<h1>Campaign Complete!</h1>
<div class="completion-banner">
<h2>🎉 {{ session.campaign.title }} - VICTORY! 🎉</h2>
<p>{{ session.campaign.description }}</p>
</div>
<div class="completion-stats">
<p><strong>Time:</strong> {{ session_duration }} (Completion Bonus: +{{ completion_bonus_percent }}%)</p>
</div>
<div class="rewards-summary">
<h3>Rewards:</h3>
<ul>
<li>💰 {{ rewards.gold_per_player }} gold ({{ base_gold }} + {{ bonus_gold }} bonus)</li>
<li>⭐ {{ rewards.experience_per_player }} XP ({{ base_xp }} + {{ bonus_xp }} bonus)</li>
<li>📦 Loot: {{ rewards.shared_items|join(', ') }}</li>
</ul>
{% if leveled_up %}
<p class="level-up">🎊 Level Up! {{ current_character.name }} reached level {{ current_character.level }}!</p>
{% endif %}
</div>
<div class="party-stats">
<h3>Party Members:</h3>
<ul>
{% for member in session.party_members %}
<li>
<strong>{{ member.username }}</strong> ({{ member.character_snapshot.name }})
{% if member.mvp_badge %}
- {{ member.mvp_badge }}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div class="actions">
<a href="/sessions/history/{{ session.session_id }}" class="btn btn-secondary">View Full Session Log</a>
<a href="/dashboard" class="btn btn-primary">Return to Main Menu</a>
</div>
</div>
{% endblock %}
```
---
## HTMX Integration Patterns
### Form Submissions
All multiplayer actions use HTMX for seamless AJAX submissions:
**Pattern:**
```html
<form hx-post="/api/v1/sessions/multiplayer/{session_id}/action"
hx-target="#result-div"
hx-swap="innerHTML">
<!-- form fields -->
<button type="submit">Submit</button>
</form>
```
**Benefits:**
- No page reload
- Partial page updates
- Progressive enhancement (works without JS)
### Realtime Updates
Combine HTMX with Appwrite Realtime for optimal UX:
**Pattern:**
```javascript
// Listen for realtime events
client.subscribe(`channel`, response => {
// Update via HTMX partial reload
htmx.ajax('GET', '/partial-url', {
target: '#target-div',
swap: 'outerHTML'
});
});
```
### Polling Fallback
For browsers without WebSocket support, use HTMX polling:
```html
<div hx-get="/api/v1/sessions/multiplayer/{session_id}/status"
hx-trigger="every 5s"
hx-target="#status-div">
<!-- Status updates every 5 seconds -->
</div>
```
---
## Template Organization
### Directory Structure
```
templates/multiplayer/
├── create.html # Session creation form
├── lobby.html # Lobby screen
├── session.html # Active session UI
├── complete.html # Session complete screen
├── partials/
│ ├── party_list.html # Reusable party member list
│ ├── combat_ui.html # Combat interface partial
│ └── timer.html # Timer component
└── components/
├── invite_link.html # Invite link copy widget
└── ready_toggle.html # Ready status toggle button
```
### Partial Template Pattern
**Example:** `templates/multiplayer/partials/party_list.html`
```html
<!-- Reusable party list component -->
<div class="party-list">
{% for member in party_members %}
<div class="party-member" data-user-id="{{ member.user_id }}">
{% if member.is_host %}
<span class="crown">👑</span>
{% endif %}
<span class="username">{{ member.username }}</span>
<span class="character">{{ member.character_snapshot.name }} - {{ member.character_snapshot.player_class.name }} Lvl {{ member.character_snapshot.level }}</span>
<span class="ready-status">
{% if member.is_ready %}✅ Ready{% else %}⏳ Not Ready{% endif %}
</span>
</div>
{% endfor %}
</div>
```
**Usage:**
```html
{% include 'multiplayer/partials/party_list.html' with party_members=session.party_members %}
```
---
## JavaScript/Appwrite Realtime
### Setup
**Base template** (`templates/base.html`):
```html
<head>
<!-- Appwrite SDK -->
<script src="https://cdn.jsdelivr.net/npm/appwrite@latest"></script>
</head>
```
### Subscription Patterns
**Session Updates:**
```javascript
const client = new Appwrite.Client()
.setEndpoint('{{ config.appwrite_endpoint }}')
.setProject('{{ config.appwrite_project_id }}');
// Subscribe to specific session
client.subscribe(
`databases.{{ db_id }}.collections.multiplayer_sessions.documents.{{ session_id }}`,
response => {
console.log('Session updated:', response.payload);
// Handle different event types
switch(response.events[0]) {
case 'databases.*.collections.*.documents.*.update':
handleSessionUpdate(response.payload);
break;
}
}
);
```
**Combat Updates:**
```javascript
client.subscribe(
`databases.{{ db_id }}.collections.combat_encounters.documents.{{ encounter_id }}`,
response => {
updateCombatUI(response.payload);
}
);
```
### Error Handling
```javascript
client.subscribe(channel, response => {
// Handle updates
}, error => {
console.error('Realtime error:', error);
// Fallback to polling
startPolling();
});
function startPolling() {
setInterval(() => {
htmx.ajax('GET', '/api/v1/sessions/multiplayer/{{ session_id }}', {
target: '#session-container',
swap: 'outerHTML'
});
}, 5000);
}
```
---
## View Layer Implementation
### Flask View Functions
**Create Session View:**
```python
@multiplayer_bp.route('/create', methods=['GET', 'POST'])
@require_auth
def create_session():
"""Render session creation form."""
if request.method == 'POST':
# Forward to API backend
response = requests.post(
f"{API_BASE_URL}/api/v1/sessions/multiplayer/create",
json=request.form.to_dict(),
headers={"Authorization": f"Bearer {session['auth_token']}"}
)
if response.status_code == 200:
session_data = response.json()['result']
return redirect(url_for('multiplayer.lobby', session_id=session_data['session_id']))
else:
flash('Failed to create session', 'error')
return render_template('multiplayer/create.html')
@multiplayer_bp.route('/lobby/<session_id>')
@require_auth
def lobby(session_id):
"""Display lobby screen."""
# Fetch session from API
response = requests.get(
f"{API_BASE_URL}/api/v1/sessions/multiplayer/{session_id}",
headers={"Authorization": f"Bearer {session['auth_token']}"}
)
session_data = response.json()['result']
return render_template('multiplayer/lobby.html', session=session_data)
```
---
## Testing Checklist
### Manual Testing Tasks
- [ ] Session creation form submits correctly
- [ ] Invite link copies to clipboard
- [ ] Lobby updates when players join
- [ ] Ready status toggles work
- [ ] Host can start session when all ready
- [ ] Timer displays and counts down correctly
- [ ] Party HP updates during combat
- [ ] Combat actions submit correctly
- [ ] Turn order highlights current player
- [ ] Realtime updates work across multiple browsers
- [ ] Session completion screen displays rewards
- [ ] Disconnection handling shows warnings
---
## Related Documentation
- **[/api/docs/MULTIPLAYER.md](../../api/docs/MULTIPLAYER.md)** - Backend API endpoints and business logic
- **[TEMPLATES.md](TEMPLATES.md)** - Template structure and conventions
- **[HTMX_PATTERNS.md](HTMX_PATTERNS.md)** - HTMX integration patterns
- **[TESTING.md](TESTING.md)** - Manual testing guide
---
**Document Version:** 1.0 (Microservices Split)
**Created:** November 18, 2025
**Last Updated:** November 18, 2025

25
public_web/docs/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Public Web Frontend Documentation
This folder contains documentation specific to the public web frontend service.
## Documents
- **[TEMPLATES.md](TEMPLATES.md)** - Template structure, naming conventions, and Jinja2 best practices
- **[HTMX_PATTERNS.md](HTMX_PATTERNS.md)** - HTMX integration patterns and dynamic UI updates
- **[TESTING.md](TESTING.md)** - Manual testing checklist and browser testing guide
- **[MULTIPLAYER.md](MULTIPLAYER.md)** - Multiplayer lobby and session UI implementation
## Quick Reference
**Service Role:** Thin UI layer that makes HTTP requests to API backend
**Tech Stack:** Flask + Jinja2 + HTMX + Vanilla CSS
**Port:** 5001 (development), 8080 (production)
## Related Documentation
- **[../CLAUDE.md](../CLAUDE.md)** - Web frontend development guidelines
- **[../README.md](../README.md)** - Setup and usage guide
- **[../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md)** - System architecture overview
- **[../../api/docs/API_REFERENCE.md](../../api/docs/API_REFERENCE.md)** - API endpoints to call

View File

@@ -0,0 +1,431 @@
# Template Structure and Conventions - Public Web Frontend
**Last Updated:** November 18, 2025
---
## Overview
This document outlines the template structure, naming conventions, and best practices for Jinja2 templates in the Code of Conquest web frontend.
**Template Philosophy:**
- Clean, semantic HTML
- Separation of concerns (templates, styles, scripts)
- Reusable components via includes and macros
- Responsive design patterns
- Accessibility-first
---
## Directory Structure
```
templates/
├── base.html # Base template (all pages extend this)
├── errors/ # Error pages
│ ├── 404.html
│ ├── 500.html
│ └── 403.html
├── auth/ # Authentication pages
│ ├── login.html
│ ├── register.html
│ └── forgot_password.html
├── dashboard/ # User dashboard
│ └── index.html
├── characters/ # Character management
│ ├── list.html
│ ├── create.html
│ ├── view.html
│ └── edit.html
├── sessions/ # Game sessions
│ ├── create.html
│ ├── active.html
│ ├── history.html
│ └── view.html
├── multiplayer/ # Multiplayer sessions
│ ├── create.html
│ ├── lobby.html
│ ├── session.html
│ └── complete.html
├── partials/ # Reusable partial templates
│ ├── navigation.html
│ ├── footer.html
│ ├── character_card.html
│ ├── combat_ui.html
│ └── session_summary.html
├── components/ # Reusable UI components (macros)
│ ├── forms.html
│ ├── buttons.html
│ ├── alerts.html
│ └── modals.html
└── macros/ # Jinja2 macros
├── form_fields.html
└── ui_elements.html
```
---
## Base Template
**File:** `templates/base.html`
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Code of Conquest{% endblock %}</title>
<!-- CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
{% block extra_css %}{% endblock %}
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Appwrite SDK (for realtime) -->
<script src="https://cdn.jsdelivr.net/npm/appwrite@latest"></script>
{% block extra_head %}{% endblock %}
</head>
<body>
{% include 'partials/navigation.html' %}
<main class="container">
{% block flash_messages %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="alerts">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% endblock %}
{% block content %}
<!-- Page content goes here -->
{% endblock %}
</main>
{% include 'partials/footer.html' %}
<!-- JavaScript -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
```
---
## Template Naming Conventions
### File Names
- Use lowercase with underscores: `character_list.html`, `session_create.html`
- Partial templates prefix with underscore: `_card.html`, `_form.html` (optional)
- Component files describe what they contain: `forms.html`, `buttons.html`
### Template Variables
- Use snake_case: `character`, `session_data`, `user_info`
- Prefix collections with descriptive names: `characters_list`, `sessions_active`
- Boolean flags use `is_` or `has_` prefix: `is_authenticated`, `has_premium`
### Block Names
- Use descriptive names: `{% block sidebar %}`, `{% block page_header %}`
- Common blocks:
- `title` - Page title
- `content` - Main content area
- `extra_css` - Additional CSS files
- `extra_js` - Additional JavaScript files
- `extra_head` - Additional head elements
---
## Template Patterns
### Extending Base Template
```html
{% extends "base.html" %}
{% block title %}Character List - Code of Conquest{% endblock %}
{% block content %}
<div class="character-list">
<h1>Your Characters</h1>
<!-- Content -->
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/characters.js') }}"></script>
{% endblock %}
```
### Including Partial Templates
```html
{% include 'partials/character_card.html' with character=char %}
```
**Or without context:**
```html
{% include 'partials/navigation.html' %}
```
### Using Macros
**Define macro** in `templates/macros/form_fields.html`:
```html
{% macro text_input(name, label, value="", required=False, placeholder="") %}
<div class="form-group">
<label for="{{ name }}">{{ label }}{% if required %} <span class="required">*</span>{% endif %}</label>
<input type="text"
id="{{ name }}"
name="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}>
</div>
{% endmacro %}
```
**Use macro:**
```html
{% from 'macros/form_fields.html' import text_input %}
<form>
{{ text_input('character_name', 'Character Name', required=True, placeholder='Enter name') }}
</form>
```
### Conditional Rendering
```html
{% if user.is_authenticated %}
<p>Welcome, {{ user.username }}!</p>
{% else %}
<a href="{{ url_for('auth.login') }}">Login</a>
{% endif %}
```
### Loops
```html
<div class="character-grid">
{% for character in characters %}
<div class="character-card">
<h3>{{ character.name }}</h3>
<p>Level {{ character.level }} {{ character.player_class.name }}</p>
</div>
{% else %}
<p>No characters found. <a href="{{ url_for('characters.create') }}">Create one</a>?</p>
{% endfor %}
</div>
```
---
## HTMX Integration in Templates
### Basic HTMX Attributes
```html
<!-- Form submission via HTMX -->
<form hx-post="{{ url_for('characters.create') }}"
hx-target="#character-list"
hx-swap="beforeend">
<!-- form fields -->
<button type="submit">Create Character</button>
</form>
```
### HTMX with Confirmation
```html
<button hx-delete="{{ url_for('characters.delete', character_id=char.character_id) }}"
hx-confirm="Are you sure you want to delete this character?"
hx-target="closest .character-card"
hx-swap="outerHTML">
Delete
</button>
```
### HTMX Polling
```html
<div hx-get="{{ url_for('sessions.status', session_id=session.session_id) }}"
hx-trigger="every 5s"
hx-target="#session-status">
Loading...
</div>
```
---
## Component Patterns
### Character Card Component
**File:** `templates/partials/character_card.html`
```html
<div class="character-card" data-character-id="{{ character.character_id }}">
<div class="card-header">
<h3>{{ character.name }}</h3>
<span class="level-badge">Lvl {{ character.level }}</span>
</div>
<div class="card-body">
<p class="class">{{ character.player_class.name }}</p>
<p class="stats">
HP: {{ character.current_hp }}/{{ character.max_hp }} |
Gold: {{ character.gold }}
</p>
</div>
<div class="card-actions">
<a href="{{ url_for('characters.view', character_id=character.character_id) }}" class="btn btn-primary">View</a>
<a href="{{ url_for('sessions.create', character_id=character.character_id) }}" class="btn btn-secondary">Play</a>
</div>
</div>
```
**Usage:**
```html
{% for character in characters %}
{% include 'partials/character_card.html' with character=character %}
{% endfor %}
```
### Alert Component Macro
**File:** `templates/components/alerts.html`
```html
{% macro alert(message, category='info', dismissible=True) %}
<div class="alert alert-{{ category }}{% if dismissible %} alert-dismissible{% endif %}" role="alert">
{{ message }}
{% if dismissible %}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
{% endif %}
</div>
{% endmacro %}
```
**Usage:**
```html
{% from 'components/alerts.html' import alert %}
{{ alert('Character created successfully!', 'success') }}
{{ alert('Invalid character name', 'error') }}
```
---
## Accessibility Guidelines
### Semantic HTML
- Use proper heading hierarchy (`<h1>`, `<h2>`, etc.)
- Use `<nav>`, `<main>`, `<article>`, `<section>` elements
- Use `<button>` for actions, `<a>` for navigation
### ARIA Labels
```html
<button aria-label="Delete character">
<span class="icon icon-trash"></span>
</button>
<nav aria-label="Main navigation">
<!-- navigation links -->
</nav>
```
### Form Labels
```html
<label for="character-name">Character Name</label>
<input type="text" id="character-name" name="character_name">
```
### Focus Management
```html
<button class="btn" autofocus>Primary Action</button>
```
---
## Best Practices
### 1. Keep Templates DRY (Don't Repeat Yourself)
- Use includes for repeated sections
- Create macros for reusable components
- Extend base template consistently
### 2. Separate Logic from Presentation
- Avoid complex Python logic in templates
- Use template filters instead of inline calculations
- Pass fully-formed data from views
### 3. Use Template Comments
```html
{# This is a Jinja2 comment, not rendered in HTML #}
<!-- This is an HTML comment, visible in source -->
```
### 4. Whitespace Control
```html
{% for item in items -%}
<li>{{ item }}</li>
{%- endfor %}
```
### 5. Template Inheritance Hierarchy
```
base.html
├── dashboard/base.html (extends base.html)
│ ├── dashboard/index.html
│ └── dashboard/profile.html
└── sessions/base.html (extends base.html)
├── sessions/create.html
└── sessions/active.html
```
---
## Testing Templates
### Manual Checklist
- [ ] Templates extend base.html correctly
- [ ] All blocks are properly closed
- [ ] Variables are defined before use
- [ ] Forms have CSRF protection
- [ ] Links use `url_for()` instead of hardcoded paths
- [ ] Images have alt text
- [ ] Buttons have descriptive text or aria-labels
- [ ] Mobile responsive (test at 320px, 768px, 1024px)
### Template Linting
- Use `djLint` for Jinja2 template linting
- Ensure consistent indentation (2 or 4 spaces)
- Validate HTML with W3C validator
---
## Related Documentation
- **[HTMX_PATTERNS.md](HTMX_PATTERNS.md)** - HTMX integration patterns
- **[TESTING.md](TESTING.md)** - Manual testing guide
- **[/api/docs/API_REFERENCE.md](../../api/docs/API_REFERENCE.md)** - API endpoints for HTMX calls
---
**Document Version:** 1.0
**Created:** November 18, 2025
**Last Updated:** November 18, 2025

710
public_web/docs/TESTING.md Normal file
View File

@@ -0,0 +1,710 @@
# Manual Testing Guide - Public Web Frontend
**Last Updated:** November 18, 2025
---
## Overview
This document outlines the manual testing procedures for the Code of Conquest web frontend. Since we prefer manual testing over automated UI tests, this guide provides comprehensive checklists for testing all features.
**Testing Philosophy:**
- Test in multiple browsers (Chrome, Firefox, Safari, Edge)
- Test on multiple devices (Desktop, Tablet, Mobile)
- Test with and without JavaScript enabled (progressive enhancement)
- Test realtime features with multiple concurrent users
- Test all HTMX interactions
---
## Testing Environment Setup
### Local Testing
```bash
# Start API backend
cd api
source venv/bin/activate
python wsgi.py # → http://localhost:5000
# Start web frontend
cd public_web
source venv/bin/activate
python wsgi.py # → http://localhost:8000
# Start Redis (for sessions)
docker-compose up redis
# Start Appwrite (for database/auth)
docker-compose up appwrite
```
### Test Browsers
- **Desktop:**
- Chrome (latest)
- Firefox (latest)
- Safari (latest, macOS only)
- Edge (latest)
- **Mobile:**
- Chrome (Android)
- Safari (iOS)
### Test Accounts
Create test accounts for each tier:
- **Free Tier:** `test_free@example.com` / `password123`
- **Basic Tier:** `test_basic@example.com` / `password123`
- **Premium Tier:** `test_premium@example.com` / `password123`
- **Elite Tier:** `test_elite@example.com` / `password123`
---
## Authentication Testing
### Registration
**Test Steps:**
1. Navigate to `/register`
2. Fill in registration form:
- Username: `testuser`
- Email: `testuser@example.com`
- Password: `SecurePassword123!`
- Confirm Password: `SecurePassword123!`
3. Click "Register"
**Expected Results:**
- [ ] Form validates (required fields, email format, password strength)
- [ ] User account created in Appwrite
- [ ] Redirected to `/dashboard`
- [ ] Session cookie set
- [ ] Welcome message displayed
**Error Cases to Test:**
- [ ] Empty fields show validation errors
- [ ] Invalid email format rejected
- [ ] Password mismatch shows error
- [ ] Weak password rejected
- [ ] Duplicate email shows error
### Login
**Test Steps:**
1. Navigate to `/login`
2. Enter credentials
3. Click "Login"
**Expected Results:**
- [ ] User authenticated
- [ ] Redirected to `/dashboard`
- [ ] Session cookie set
- [ ] Navigation shows user menu
**Error Cases:**
- [ ] Invalid credentials show error
- [ ] Empty fields show validation
- [ ] Account lockout after 5 failed attempts (if implemented)
### Logout
**Test Steps:**
1. Click "Logout" in navigation
2. Confirm logout
**Expected Results:**
- [ ] Session destroyed
- [ ] Redirected to `/`
- [ ] Navigation shows "Login" link
- [ ] Cannot access protected pages
---
## Character Management Testing
### Character Creation
**Test Steps:**
1. Navigate to `/characters/create`
2. Fill in character form:
- Name: `Thorin Ironshield`
- Class: `Vanguard`
- Background: `Soldier`
3. Click "Create Character"
**Expected Results:**
- [ ] Character created via API
- [ ] Redirected to `/characters/{character_id}`
- [ ] Character details displayed
- [ ] Character appears in character list
**HTMX Behavior:**
- [ ] Form submits without page reload (if using HTMX)
- [ ] Success message appears
- [ ] Form resets or replaced with character card
**Error Cases:**
- [ ] Name validation (3-50 characters)
- [ ] Required fields enforced
- [ ] Duplicate name handling
### Character List
**Test Steps:**
1. Navigate to `/characters`
2. View character list
**Expected Results:**
- [ ] All user's characters displayed
- [ ] Character cards show: name, class, level, HP
- [ ] "Create Character" button visible
- [ ] Empty state if no characters
**Interactions:**
- [ ] Click character card → `/characters/{id}`
- [ ] Click "Play" → creates session
- [ ] Click "Delete" → confirmation modal
- [ ] Search/filter works (if implemented)
### Character View
**Test Steps:**
1. Navigate to `/characters/{character_id}`
2. View character details
**Expected Results:**
- [ ] Full character sheet displayed
- [ ] Stats, abilities, inventory shown
- [ ] Equipment slots visible
- [ ] Edit/Delete buttons visible (if owner)
### Character Edit
**Test Steps:**
1. Navigate to `/characters/{character_id}/edit`
2. Modify character details
3. Click "Save"
**Expected Results:**
- [ ] Changes saved via API
- [ ] Redirected to character view
- [ ] Updates visible immediately
**Inline Edit (HTMX):**
- [ ] Click name to edit inline
- [ ] Save without page reload
- [ ] Cancel reverts changes
### Character Delete
**Test Steps:**
1. Click "Delete" on character card
2. Confirm deletion
**Expected Results:**
- [ ] Confirmation modal appears
- [ ] Character deleted via API
- [ ] Character removed from list (HTMX removes element)
- [ ] Success message displayed
---
## Session Testing
### Session Creation
**Test Steps:**
1. Navigate to `/sessions/create`
2. Select character
3. Click "Start Session"
**Expected Results:**
- [ ] Session created via API
- [ ] Redirected to `/sessions/{session_id}`
- [ ] Session UI loaded
- [ ] Timer started (if applicable)
### Active Session
**Test Steps:**
1. Navigate to `/sessions/{session_id}`
2. Interact with session UI
**Expected Results:**
- [ ] Character info displayed
- [ ] Location/narrative shown
- [ ] Action buttons visible
- [ ] Conversation history loaded
**Interactions:**
- [ ] Click action button → sends to API
- [ ] Response appears in conversation
- [ ] HTMX updates without page reload
- [ ] Scrolls to latest message
### Combat Session
**Test Steps:**
1. Start combat encounter
2. Take combat action
**Expected Results:**
- [ ] Combat UI displayed
- [ ] Turn order shown
- [ ] Action buttons enabled on player's turn
- [ ] Damage/effects displayed
- [ ] HP bars update
**HTMX Behavior:**
- [ ] Combat actions submit via HTMX
- [ ] Combat log updates dynamically
- [ ] Turn advances without reload
- [ ] Victory/defeat screen appears
### Session History
**Test Steps:**
1. Navigate to `/sessions/history`
2. View past sessions
**Expected Results:**
- [ ] All sessions listed (active and completed)
- [ ] Session summaries shown
- [ ] Click session → view details
- [ ] Filter/search works (if implemented)
---
## Multiplayer Testing
### Create Multiplayer Session
**Test Steps:**
1. Navigate to `/multiplayer/create`
2. Select party size (4 players)
3. Select difficulty (Medium)
4. Click "Create Session"
**Expected Results:**
- [ ] Session created via API
- [ ] Redirected to lobby
- [ ] Invite code displayed
- [ ] Invite link copyable
### Lobby (Host)
**Test Steps:**
1. View lobby as host
2. Wait for players to join
**Expected Results:**
- [ ] Host badge displayed
- [ ] Player list updates when players join (realtime)
- [ ] "Start Session" button disabled until all ready
- [ ] "Start Session" enabled when all ready
**Realtime Updates:**
- [ ] New player joins → list updates
- [ ] Player ready status changes → updates
- [ ] No page reload required
### Lobby (Player)
**Test Steps:**
1. Click invite link in different browser
2. Select character
3. Join session
**Expected Results:**
- [ ] Session info displayed
- [ ] Character selection shown
- [ ] Join button enabled
- [ ] Redirected to lobby after join
**Lobby Actions:**
- [ ] Toggle "Ready" status
- [ ] See other players' status update
- [ ] Host starts session → redirect to active session
### Active Multiplayer Session
**Test Steps:**
1. Play through multiplayer session
2. Take turns in combat
**Expected Results:**
- [ ] Campaign title displayed
- [ ] Timer counts down
- [ ] Party status shows all members
- [ ] Narrative updates
- [ ] Combat turn order shown
**Realtime Behavior:**
- [ ] Other players' actions appear immediately
- [ ] Turn advances when player acts
- [ ] HP updates across all clients
- [ ] Timer warnings appear
**Combat Turn:**
- [ ] Action buttons enabled only on your turn
- [ ] "Waiting for other players..." when not your turn
- [ ] Action submits via HTMX
- [ ] Combat log updates
### Session Complete
**Test Steps:**
1. Complete multiplayer session
2. View rewards screen
**Expected Results:**
- [ ] Victory message displayed
- [ ] Completion time shown
- [ ] Rewards listed (gold, XP, items)
- [ ] Level up notification (if applicable)
- [ ] Party stats shown (MVP, etc.)
---
## Responsive Design Testing
### Mobile (320px - 480px)
**Test on:**
- iPhone SE (375x667)
- iPhone 12 (390x844)
- Android (360x640)
**Check:**
- [ ] Navigation menu collapses to hamburger
- [ ] Forms are usable (inputs not cut off)
- [ ] Buttons are tap-friendly (min 44x44px)
- [ ] Character cards stack vertically
- [ ] Combat UI scales properly
- [ ] No horizontal scroll
### Tablet (481px - 768px)
**Test on:**
- iPad Mini (768x1024)
- iPad Air (820x1180)
**Check:**
- [ ] Layout uses medium breakpoint
- [ ] Character grid shows 2 columns
- [ ] Navigation partially expanded
- [ ] Touch targets adequate
### Desktop (769px+)
**Test on:**
- 1024x768 (small desktop)
- 1920x1080 (standard desktop)
- 2560x1440 (large desktop)
**Check:**
- [ ] Full navigation visible
- [ ] Character grid shows 3-4 columns
- [ ] Combat UI uses full width
- [ ] No excessive whitespace
---
## Accessibility Testing
### Keyboard Navigation
**Test Steps:**
1. Navigate site using only keyboard (Tab, Shift+Tab, Enter, Space)
2. Test all interactive elements
**Expected Results:**
- [ ] All links/buttons focusable
- [ ] Focus indicator visible
- [ ] Logical tab order
- [ ] Forms submittable via Enter
- [ ] Modals closable via Escape
### Screen Reader
**Test with:**
- NVDA (Windows)
- JAWS (Windows)
- VoiceOver (macOS/iOS)
**Check:**
- [ ] All images have alt text
- [ ] Form labels associated correctly
- [ ] ARIA labels on icon-only buttons
- [ ] Headings in logical hierarchy
- [ ] Focus announcements clear
### Color Contrast
**Tools:**
- WAVE (browser extension)
- axe DevTools (browser extension)
**Check:**
- [ ] Text contrast ≥ 4.5:1 (AA)
- [ ] Large text contrast ≥ 3:1 (AA)
- [ ] Interactive elements have visible focus
- [ ] Color not sole indicator (e.g., errors)
---
## HTMX Specific Testing
### Form Submission
**Test:**
- [ ] Form submits without page reload
- [ ] Success response replaces form
- [ ] Error response shows validation
- [ ] Loading indicator appears
- [ ] Double-submit prevented
### Delete Actions
**Test:**
- [ ] Confirmation dialog appears
- [ ] Element removed on success
- [ ] Error message on failure
- [ ] No page reload
### Live Search
**Test:**
- [ ] Search triggers after typing stops (debounce)
- [ ] Results update without reload
- [ ] Loading indicator shown
- [ ] Empty state displayed correctly
### Polling
**Test:**
- [ ] Content updates at interval
- [ ] Polling stops when condition met
- [ ] Network errors handled gracefully
### Realtime + HTMX Hybrid
**Test:**
- [ ] Appwrite Realtime triggers HTMX reload
- [ ] Multiple browser tabs sync
- [ ] No duplicate updates
- [ ] Disconnection handled
---
## Error Handling Testing
### API Errors
**Simulate:**
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 500 Internal Server Error
**Expected:**
- [ ] User-friendly error message displayed
- [ ] Error logged to console (dev only)
- [ ] No sensitive data exposed
- [ ] Retry option available (if applicable)
### Network Errors
**Simulate:**
- Disconnect network
- Slow 3G connection
- API backend down
**Expected:**
- [ ] Loading indicator appears
- [ ] Timeout after reasonable delay
- [ ] Error message displayed
- [ ] Fallback behavior (if applicable)
---
## Performance Testing
### Page Load Speed
**Test:**
- [ ] First contentful paint < 1.5s
- [ ] Time to interactive < 3s
- [ ] No layout shift (CLS < 0.1)
**Tools:**
- Chrome DevTools (Lighthouse)
- WebPageTest.org
### HTMX Requests
**Test:**
- [ ] Requests complete < 500ms (local)
- [ ] Debouncing prevents spam
- [ ] Caching used where appropriate
---
## Browser Compatibility
### Feature Detection
**Test:**
- [ ] Works without JavaScript (forms submit)
- [ ] WebSocket support detected
- [ ] Fallback to polling if no WebSocket
- [ ] HTMX gracefully degrades
### Browser-Specific Issues
**Chrome:**
- [ ] HTMX works correctly
- [ ] Realtime WebSocket stable
**Firefox:**
- [ ] All features functional
- [ ] No console errors
**Safari:**
- [ ] WebSocket support
- [ ] Form validation
- [ ] CSS grid/flexbox
**Edge:**
- [ ] All features functional
---
## Security Testing
### Authentication
**Test:**
- [ ] Protected routes redirect to login
- [ ] Session timeout works
- [ ] Cannot access other users' data
- [ ] CSRF protection enabled on forms
### Input Validation
**Test:**
- [ ] XSS prevention (input sanitized)
- [ ] SQL injection prevention (API handles)
- [ ] File upload validation (if applicable)
- [ ] Rate limiting enforced
---
## Testing Checklist Template
Copy this template for each release:
```markdown
## Release X.X.X Testing Checklist
**Tester:** [Your Name]
**Date:** [YYYY-MM-DD]
**Environment:** [Local / Staging / Production]
**Browser:** [Chrome / Firefox / Safari / Edge]
### Authentication
- [ ] Registration works
- [ ] Login works
- [ ] Logout works
### Characters
- [ ] Create character
- [ ] View character list
- [ ] Edit character
- [ ] Delete character
### Sessions
- [ ] Create solo session
- [ ] Active session UI
- [ ] Combat works
- [ ] Session history
### Multiplayer
- [ ] Create multiplayer session
- [ ] Lobby (host)
- [ ] Lobby (player)
- [ ] Active multiplayer session
- [ ] Session complete
### Responsive
- [ ] Mobile (375px)
- [ ] Tablet (768px)
- [ ] Desktop (1920px)
### Accessibility
- [ ] Keyboard navigation
- [ ] Screen reader compatible
- [ ] Color contrast
### Performance
- [ ] Page load < 3s
- [ ] No console errors
- [ ] HTMX requests fast
### Notes:
[Any issues found]
```
---
## Reporting Issues
### Issue Template
```markdown
**Title:** [Brief description]
**Environment:**
- Browser: [Chrome 120]
- OS: [Windows 11]
- Screen Size: [1920x1080]
**Steps to Reproduce:**
1. Navigate to `/characters`
2. Click "Delete" on character card
3. Confirm deletion
**Expected Result:**
Character should be deleted and removed from list.
**Actual Result:**
Character deleted but card still visible until page refresh.
**Console Errors:**
[Paste any console errors]
**Screenshots:**
[Attach screenshots if applicable]
**Severity:**
- [ ] Critical (blocking)
- [ ] High (major feature broken)
- [x] Medium (feature degraded)
- [ ] Low (cosmetic)
```
---
## Related Documentation
- **[TEMPLATES.md](TEMPLATES.md)** - Template structure
- **[HTMX_PATTERNS.md](HTMX_PATTERNS.md)** - HTMX integration patterns
- **[/api/docs/API_TESTING.md](../../api/docs/API_TESTING.md)** - API backend testing
---
**Document Version:** 1.0
**Created:** November 18, 2025
**Last Updated:** November 18, 2025

View File

@@ -0,0 +1,30 @@
# Web Framework
Flask>=3.0.0,<4.0.0
Jinja2>=3.1.0,<4.0.0
Werkzeug>=3.0.0,<4.0.0
# WSGI Server (Production)
gunicorn>=21.2.0,<22.0.0
# HTTP Client (for API calls)
requests>=2.31.0,<3.0.0
# Logging
structlog>=24.1.0,<25.0.0
python-json-logger>=2.0.7,<3.0.0
# Configuration
PyYAML>=6.0.1,<7.0.0
python-dotenv>=1.0.0,<2.0.0
# CORS (if needed for local development)
Flask-CORS>=4.0.0,<5.0.0
# Security
bleach>=6.1.0,<7.0.0
# Date/Time
python-dateutil>=2.8.2,<3.0.0
# Utilities
click>=8.1.7,<9.0.0

View File

@@ -0,0 +1,608 @@
/**
* Code of Conquest - Main Stylesheet
* RPG/Fantasy Theme with Dark Slate Gray Color Scheme
*/
/* ===== CSS VARIABLES ===== */
:root {
/* Backgrounds - Approved colors from mockup */
--bg-primary: #2C3E50;
--bg-secondary: #2A3947; /* Darkened for better contrast */
--bg-tertiary: #1A252F;
--bg-input: #1F2D3A; /* Darkened for better contrast */
/* Accents */
--accent-gold: #F39C12;
--accent-gold-hover: #E67E22;
--accent-red: #C0392B;
--accent-red-light: #E74C3C; /* Lighter for better contrast on dark backgrounds */
--accent-green: #27AE60;
--accent-blue: #3498DB;
/* Text */
--text-primary: #ECF0F1;
--text-secondary: #BDC3C7;
--text-muted: #95A5A6;
/* Borders */
--border-primary: #4A5F7F;
--border-ornate: #8B7355;
--border-glow: #F39C12;
/* Shadows */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(243, 156, 18, 0.3);
/* Typography */
--font-heading: 'Cinzel', serif;
--font-body: 'Lato', sans-serif;
--font-mono: 'Courier New', monospace;
/* Font Sizes */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
}
/* ===== RESET & BASE STYLES ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===== HEADER ===== */
.header {
background: var(--bg-tertiary);
padding: 1.5rem 2rem;
border-bottom: 2px solid var(--border-ornate);
box-shadow: var(--shadow-md);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: var(--font-heading);
font-size: var(--text-2xl);
font-weight: 700;
color: var(--accent-gold);
text-decoration: none;
text-transform: uppercase;
letter-spacing: 2px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.logo:hover {
color: var(--accent-gold-hover);
text-shadow: 0 0 10px rgba(243, 156, 18, 0.5);
}
.header-nav {
display: flex;
align-items: center;
gap: 1.5rem;
}
.user-greeting {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.logout-form {
display: inline;
}
.btn-link {
background: none;
border: none;
color: var(--accent-gold);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: color 0.3s ease;
}
.btn-link:hover {
color: var(--accent-gold-hover);
text-shadow: 0 0 8px rgba(243, 156, 18, 0.3);
}
/* ===== MAIN CONTENT ===== */
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
/* ===== AUTH CONTAINER ===== */
.auth-container {
max-width: 450px;
width: 100%;
padding: 2.5rem;
background: var(--bg-secondary);
border: 2px solid var(--border-ornate);
border-radius: 8px;
box-shadow: var(--shadow-lg);
position: relative;
}
/* Ornate corner decorations */
.auth-container::before,
.auth-container::after {
content: '';
position: absolute;
width: 40px;
height: 40px;
border: 2px solid var(--accent-gold);
}
.auth-container::before {
top: -2px;
left: -2px;
border-right: none;
border-bottom: none;
}
.auth-container::after {
bottom: -2px;
right: -2px;
border-left: none;
border-top: none;
}
/* ===== TYPOGRAPHY ===== */
.page-title {
font-family: var(--font-heading);
font-size: var(--text-3xl);
font-weight: 700;
color: var(--accent-gold);
text-align: center;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 2px;
}
.page-subtitle {
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--text-secondary);
text-align: center;
margin-bottom: 2rem;
}
/* ===== FORMS ===== */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-family: var(--font-heading);
font-size: var(--text-sm);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-base);
transition: all 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: var(--accent-gold);
box-shadow: 0 0 10px rgba(243, 156, 18, 0.2);
}
.form-input::placeholder {
color: var(--text-muted);
}
/* Checkbox styling */
.checkbox-group {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
}
.checkbox-input {
width: 18px;
height: 18px;
margin-right: 0.5rem;
accent-color: var(--accent-gold);
}
.checkbox-label {
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
}
/* ===== BUTTONS ===== */
.btn {
display: inline-block;
width: 100%;
padding: 0.875rem 2rem;
font-family: var(--font-heading);
font-size: var(--text-base);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
border: 2px solid;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
text-decoration: none;
text-align: center;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent-gold) 0%, var(--accent-gold-hover) 100%);
border-color: var(--accent-gold);
color: var(--bg-primary);
}
.btn-primary:hover {
box-shadow: var(--shadow-glow);
transform: translateY(-2px);
}
.btn-primary::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn-primary:hover::before {
width: 300px;
height: 300px;
}
.btn-secondary {
background: transparent;
border-color: var(--border-primary);
color: var(--text-primary);
margin-top: 1rem;
}
.btn-secondary:hover {
border-color: var(--accent-gold);
color: var(--accent-gold);
background: rgba(243, 156, 18, 0.1);
}
/* ===== FLASH MESSAGES ===== */
.flash-messages-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
max-width: 400px;
}
.flash-message {
padding: 1rem 1.5rem;
margin-bottom: 0.5rem;
border-radius: 4px;
font-size: var(--text-sm);
border-left: 4px solid;
position: relative;
box-shadow: var(--shadow-md);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.flash-message.flash-error {
background: rgba(192, 57, 43, 0.2);
border-color: var(--accent-red);
color: var(--text-primary);
}
.flash-message.flash-success {
background: rgba(39, 174, 96, 0.2);
border-color: var(--accent-green);
color: var(--text-primary);
}
.flash-message.flash-info {
background: rgba(52, 152, 219, 0.2);
border-color: var(--accent-blue);
color: var(--text-primary);
}
.flash-close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: none;
border: none;
color: var(--text-muted);
font-size: var(--text-xl);
cursor: pointer;
transition: color 0.3s ease;
}
.flash-close:hover {
color: var(--text-primary);
}
/* ===== ERROR/SUCCESS MESSAGES (Inline) ===== */
.message {
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border-radius: 4px;
font-size: var(--text-sm);
border-left: 4px solid;
}
.error-message {
background: rgba(192, 57, 43, 0.2);
border-color: var(--accent-red);
color: var(--text-primary);
}
.success-message {
background: rgba(39, 174, 96, 0.2);
border-color: var(--accent-green);
color: var(--text-primary);
}
.info-message {
background: rgba(52, 152, 219, 0.2);
border-color: var(--accent-blue);
color: var(--text-primary);
}
.field-error {
display: block;
margin-top: 0.25rem;
color: var(--accent-red-light); /* Using lighter red for better contrast */
font-size: var(--text-xs);
font-style: italic;
}
/* ===== LINKS ===== */
.form-links {
margin-top: 1.5rem;
text-align: center;
}
.form-link {
color: var(--accent-gold);
text-decoration: none;
font-size: var(--text-sm);
transition: color 0.3s ease;
}
.form-link:hover {
color: var(--accent-gold-hover);
text-shadow: 0 0 8px rgba(243, 156, 18, 0.3);
}
.divider {
margin: 1rem 0;
color: var(--text-muted);
font-size: var(--text-xs);
}
/* ===== FOOTER ===== */
.footer {
background: var(--bg-tertiary);
padding: 1.5rem 2rem;
border-top: 2px solid var(--border-ornate);
text-align: center;
}
.footer-text {
font-size: var(--text-sm);
color: var(--text-secondary);
}
/* ===== DECORATIVE ELEMENTS ===== */
.decorative-line {
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
var(--accent-gold) 50%,
transparent 100%);
margin: 1.5rem 0;
}
/* ===== PASSWORD STRENGTH INDICATOR ===== */
.password-strength {
margin-top: 0.5rem;
}
.strength-bar {
height: 4px;
background: var(--bg-input);
border-radius: 2px;
overflow: hidden;
margin-bottom: 0.25rem;
}
.strength-fill {
height: 100%;
width: 0%;
transition: all 0.3s ease;
}
.strength-weak {
width: 33%;
background: var(--accent-red);
}
.strength-medium {
width: 66%;
background: var(--accent-gold);
}
.strength-strong {
width: 100%;
background: var(--accent-green);
}
.strength-text {
font-size: var(--text-xs);
color: var(--text-muted);
}
/* ===== RESPONSIVE DESIGN ===== */
@media (max-width: 768px) {
.header {
padding: 1rem 1.5rem;
}
.logo {
font-size: var(--text-xl);
}
.auth-container {
padding: 2rem 1.5rem;
margin: 1rem;
}
.auth-container::before,
.auth-container::after {
width: 30px;
height: 30px;
}
.page-title {
font-size: var(--text-2xl);
}
main {
padding: 1rem;
}
.flash-messages-container {
left: 1rem;
right: 1rem;
max-width: 100%;
}
}
@media (max-width: 480px) {
.logo {
font-size: var(--text-lg);
}
.page-title {
font-size: var(--text-xl);
}
.auth-container {
padding: 1.5rem 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: var(--text-sm);
}
}
/* ===== HTMX LOADING INDICATORS ===== */
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-indicator {
display: none;
}
/* Loading spinner */
.loading-spinner {
border: 3px solid var(--bg-input);
border-top: 3px solid var(--accent-gold);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
display: inline-block;
margin-left: 0.5rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ===== UTILITY CLASSES ===== */
.text-center {
text-align: center;
}
.mt-1 {
margin-top: 0.5rem;
}
.mt-2 {
margin-top: 1rem;
}
.mb-1 {
margin-bottom: 0.5rem;
}
.mb-2 {
margin-bottom: 1rem;
}
.hidden {
display: none;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}Forgot Password - Code of Conquest{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="page-title">Forgot Password</h1>
<p class="page-subtitle">Recover your account access</p>
<div class="decorative-line"></div>
<!-- Message display area -->
<div id="forgot-password-messages"></div>
<form
id="forgot-password-form"
hx-post="{{ api_base_url }}/api/v1/auth/forgot-password"
hx-ext="json-enc"
hx-target="#forgot-password-messages"
hx-swap="innerHTML"
>
<div class="form-group">
<label class="form-label" for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="adventurer@realm.com"
required
autocomplete="email"
>
<span id="email-error" class="field-error"></span>
</div>
<button type="submit" class="btn btn-primary">
Send Reset Link
<span class="htmx-indicator loading-spinner"></span>
</button>
<button type="button" class="btn btn-secondary" onclick="window.location.href='{{ url_for('auth_views.login') }}'">
Back to Login
</button>
</form>
<div class="form-links">
<div class="divider">or</div>
<a href="{{ url_for('auth_views.register') }}" class="form-link">Don't have an account? Register here</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Handle form submission response
document.getElementById('forgot-password-form').addEventListener('htmx:afterSwap', function(event) {
const response = event.detail.xhr.responseText;
try {
const data = JSON.parse(response);
// Check if request was successful
if (data.status === 200) {
// Show success message
document.getElementById('forgot-password-messages').innerHTML =
'<div class="success-message">' +
'If an account exists with this email, you will receive a password reset link shortly. ' +
'Please check your inbox and spam folder.' +
'</div>';
// Clear form
document.getElementById('forgot-password-form').reset();
} else {
// Display error message
if (data.error && data.error.details && data.error.details.email) {
document.getElementById('email-error').textContent = data.error.details.email;
} else if (data.error && data.error.message) {
document.getElementById('forgot-password-messages').innerHTML =
'<div class="error-message">' + data.error.message + '</div>';
}
}
} catch (e) {
console.error('Error parsing response:', e);
}
});
// Clear errors when user starts typing
document.getElementById('email').addEventListener('input', function() {
document.getElementById('email-error').textContent = '';
document.getElementById('forgot-password-messages').innerHTML = '';
});
</script>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Login - Code of Conquest{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="page-title">Login</h1>
<p class="page-subtitle">Enter the realm, brave adventurer</p>
<div class="decorative-line"></div>
<!-- Error display area -->
{% if error %}
<div class="error-message">{{ error }}</div>
{% endif %}
<form
id="login-form"
method="POST"
action="{{ url_for('auth_views.login') }}"
>
<div class="form-group">
<label class="form-label" for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="adventurer@realm.com"
required
autocomplete="email"
>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="Enter your secret passphrase"
required
autocomplete="current-password"
>
</div>
<div class="checkbox-group">
<input type="checkbox" id="remember_me" name="remember_me" class="checkbox-input">
<label for="remember_me" class="checkbox-label">Remember me for 30 days</label>
</div>
<button type="submit" class="btn btn-primary">
Enter the Realm
<span class="htmx-indicator loading-spinner"></span>
</button>
</form>
<div class="form-links">
<a href="{{ url_for('auth_views.forgot_password') }}" class="form-link">Forgot your password?</a>
<div class="divider">or</div>
<a href="{{ url_for('auth_views.register') }}" class="form-link">Don't have an account? Register here</a>
</div>
</div>
{% endblock %}
{% block scripts %}
{% endblock %}

View File

@@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block title %}Register - Code of Conquest{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="page-title">Register</h1>
<p class="page-subtitle">Begin your epic journey</p>
<div class="decorative-line"></div>
<!-- Error display area for HTMX responses -->
<div id="register-errors"></div>
<form
id="register-form"
hx-post="{{ api_base_url }}/api/v1/auth/register"
hx-ext="json-enc"
hx-target="#register-errors"
hx-swap="innerHTML"
>
<div class="form-group">
<label class="form-label" for="name">Character Name</label>
<input
type="text"
id="name"
name="name"
class="form-input"
placeholder="Enter your hero's name"
required
minlength="3"
maxlength="50"
autocomplete="name"
>
<span id="name-error" class="field-error"></span>
</div>
<div class="form-group">
<label class="form-label" for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="adventurer@realm.com"
required
autocomplete="email"
>
<span id="email-error" class="field-error"></span>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="Create a strong passphrase"
required
autocomplete="new-password"
>
<div class="password-strength">
<div class="strength-bar">
<div id="strength-fill" class="strength-fill"></div>
</div>
<span id="strength-text" class="strength-text">Enter a password to see strength</span>
</div>
<span id="password-error" class="field-error"></span>
</div>
<div class="form-group">
<label class="form-label" for="confirm-password">Confirm Password</label>
<input
type="password"
id="confirm-password"
name="confirm_password"
class="form-input"
placeholder="Re-enter your passphrase"
required
autocomplete="new-password"
>
<span id="confirm-password-error" class="field-error"></span>
</div>
<button type="submit" class="btn btn-primary">
Begin Adventure
<span class="htmx-indicator loading-spinner"></span>
</button>
</form>
<div class="form-links">
<a href="{{ url_for('auth_views.login') }}" class="form-link">Already have an account? Login here</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Password strength indicator
function updatePasswordStrength(password) {
const fill = document.getElementById('strength-fill');
const text = document.getElementById('strength-text');
if (!password) {
fill.className = 'strength-fill';
text.textContent = 'Enter a password to see strength';
text.style.color = 'var(--text-muted)';
return;
}
let strength = 0;
// Check length
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
// Check for uppercase
if (/[A-Z]/.test(password)) strength++;
// Check for lowercase
if (/[a-z]/.test(password)) strength++;
// Check for numbers
if (/[0-9]/.test(password)) strength++;
// Check for special characters
if (/[^A-Za-z0-9]/.test(password)) strength++;
if (strength <= 2) {
fill.className = 'strength-fill strength-weak';
text.textContent = 'Weak - Add more complexity';
text.style.color = 'var(--accent-red)';
} else if (strength <= 4) {
fill.className = 'strength-fill strength-medium';
text.textContent = 'Medium - Almost there';
text.style.color = 'var(--accent-gold)';
} else {
fill.className = 'strength-fill strength-strong';
text.textContent = 'Strong - Excellent password';
text.style.color = 'var(--accent-green)';
}
}
// Attach password strength checker
document.getElementById('password').addEventListener('input', function() {
updatePasswordStrength(this.value);
});
// Validate password confirmation
document.getElementById('confirm-password').addEventListener('input', function() {
const password = document.getElementById('password').value;
const confirmPassword = this.value;
const errorSpan = document.getElementById('confirm-password-error');
if (confirmPassword && password !== confirmPassword) {
errorSpan.textContent = 'Passwords do not match';
} else {
errorSpan.textContent = '';
}
});
// Handle HTMX response
document.body.addEventListener('htmx:afterRequest', function(event) {
// Only handle register form
if (!event.detail.elt || event.detail.elt.id !== 'register-form') return;
try {
const xhr = event.detail.xhr;
// Check if registration was successful
if (xhr.status === 201) {
const data = JSON.parse(xhr.responseText);
// Show success message
document.getElementById('register-errors').innerHTML = '<div class="success-message">' +
'Registration successful! Please check your email to verify your account.' +
'</div>';
// Clear form
document.getElementById('register-form').reset();
updatePasswordStrength('');
// Redirect to login after delay
setTimeout(function() {
window.location.href = '{{ url_for("auth_views.login") }}';
}, 2000);
} else {
// Handle error
const data = JSON.parse(xhr.responseText);
let errorHtml = '<div class="error-message">';
if (data.error && data.error.details) {
for (const [field, message] of Object.entries(data.error.details)) {
errorHtml += `<strong>${field}:</strong> ${message}<br>`;
// Also show inline error
const errorSpan = document.getElementById(field + '-error');
if (errorSpan) {
errorSpan.textContent = message;
}
}
} else if (data.error && data.error.message) {
errorHtml += data.error.message;
} else {
errorHtml += 'An error occurred. Please try again.';
}
errorHtml += '</div>';
document.getElementById('register-errors').innerHTML = errorHtml;
}
} catch (e) {
console.error('Error parsing response:', e);
document.getElementById('register-errors').innerHTML =
'<div class="error-message">An unexpected error occurred.</div>';
}
});
// Clear errors when user starts typing
document.querySelectorAll('.form-input').forEach(input => {
input.addEventListener('input', function() {
const fieldName = this.name;
const errorSpan = document.getElementById(fieldName + '-error');
if (errorSpan) {
errorSpan.textContent = '';
}
// Clear general errors
document.getElementById('register-errors').innerHTML = '';
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}Reset Password - Code of Conquest{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="page-title">Reset Password</h1>
<p class="page-subtitle">Create a new password for your account</p>
<div class="decorative-line"></div>
<!-- Message display area -->
<div id="reset-password-messages"></div>
<form
id="reset-password-form"
hx-post="{{ api_base_url }}/api/v1/auth/reset-password"
hx-ext="json-enc"
hx-target="#reset-password-messages"
hx-swap="innerHTML"
>
<!-- Hidden fields for user_id and secret from URL -->
<input type="hidden" name="user_id" value="{{ user_id }}">
<input type="hidden" name="secret" value="{{ secret }}">
<div class="form-group">
<label class="form-label" for="password">New Password</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="Create a strong passphrase"
required
autocomplete="new-password"
>
<div class="password-strength">
<div class="strength-bar">
<div id="strength-fill" class="strength-fill"></div>
</div>
<span id="strength-text" class="strength-text">Enter a password to see strength</span>
</div>
<span id="password-error" class="field-error"></span>
</div>
<div class="form-group">
<label class="form-label" for="confirm-password">Confirm New Password</label>
<input
type="password"
id="confirm-password"
name="confirm_password"
class="form-input"
placeholder="Re-enter your passphrase"
required
autocomplete="new-password"
>
<span id="confirm-password-error" class="field-error"></span>
</div>
<button type="submit" class="btn btn-primary">
Reset Password
<span class="htmx-indicator loading-spinner"></span>
</button>
<button type="button" class="btn btn-secondary" onclick="window.location.href='{{ url_for('auth_views.login') }}'">
Back to Login
</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
// Password strength indicator
function updatePasswordStrength(password) {
const fill = document.getElementById('strength-fill');
const text = document.getElementById('strength-text');
if (!password) {
fill.className = 'strength-fill';
text.textContent = 'Enter a password to see strength';
text.style.color = 'var(--text-muted)';
return;
}
let strength = 0;
// Check length
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
// Check for uppercase
if (/[A-Z]/.test(password)) strength++;
// Check for lowercase
if (/[a-z]/.test(password)) strength++;
// Check for numbers
if (/[0-9]/.test(password)) strength++;
// Check for special characters
if (/[^A-Za-z0-9]/.test(password)) strength++;
if (strength <= 2) {
fill.className = 'strength-fill strength-weak';
text.textContent = 'Weak - Add more complexity';
text.style.color = 'var(--accent-red)';
} else if (strength <= 4) {
fill.className = 'strength-fill strength-medium';
text.textContent = 'Medium - Almost there';
text.style.color = 'var(--accent-gold)';
} else {
fill.className = 'strength-fill strength-strong';
text.textContent = 'Strong - Excellent password';
text.style.color = 'var(--accent-green)';
}
}
// Attach password strength checker
document.getElementById('password').addEventListener('input', function() {
updatePasswordStrength(this.value);
});
// Validate password confirmation
document.getElementById('confirm-password').addEventListener('input', function() {
const password = document.getElementById('password').value;
const confirmPassword = this.value;
const errorSpan = document.getElementById('confirm-password-error');
if (confirmPassword && password !== confirmPassword) {
errorSpan.textContent = 'Passwords do not match';
} else {
errorSpan.textContent = '';
}
});
// Validate before submit
document.getElementById('reset-password-form').addEventListener('submit', function(e) {
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm-password').value;
if (password !== confirmPassword) {
e.preventDefault();
document.getElementById('confirm-password-error').textContent = 'Passwords do not match';
return false;
}
});
// Handle form submission response
document.getElementById('reset-password-form').addEventListener('htmx:afterSwap', function(event) {
const response = event.detail.xhr.responseText;
try {
const data = JSON.parse(response);
// Check if reset was successful
if (data.status === 200) {
// Show success message
document.getElementById('reset-password-messages').innerHTML =
'<div class="success-message">' +
'Password reset successful! Redirecting to login...' +
'</div>';
// Redirect to login after delay
setTimeout(function() {
window.location.href = '{{ url_for("auth_views.login") }}';
}, 2000);
} else {
// Display error message
if (data.error && data.error.details) {
let errorHtml = '<div class="error-message">';
for (const [field, message] of Object.entries(data.error.details)) {
errorHtml += `<strong>${field}:</strong> ${message}<br>`;
const errorSpan = document.getElementById(field + '-error');
if (errorSpan) {
errorSpan.textContent = message;
}
}
errorHtml += '</div>';
document.getElementById('reset-password-messages').innerHTML = errorHtml;
} else if (data.error && data.error.message) {
document.getElementById('reset-password-messages').innerHTML =
'<div class="error-message">' + data.error.message + '</div>';
}
}
} catch (e) {
console.error('Error parsing response:', e);
}
});
// Clear errors when user starts typing
document.querySelectorAll('.form-input').forEach(input => {
input.addEventListener('input', function() {
const fieldName = this.name;
const errorSpan = document.getElementById(fieldName + '-error');
if (errorSpan) {
errorSpan.textContent = '';
}
document.getElementById('reset-password-messages').innerHTML = '';
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Verify Email - Code of Conquest{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="page-title">Email Verification</h1>
<p class="page-subtitle">Confirming your email address</p>
<div class="decorative-line"></div>
<div class="text-center">
<div class="success-message">
Your email has been verified successfully!
</div>
<p class="mt-2 mb-2" style="color: var(--text-secondary);">
You can now log in to your account and begin your adventure.
</p>
<button class="btn btn-primary" onclick="window.location.href='{{ url_for('auth_views.login') }}'">
Go to Login
</button>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Code of Conquest - An AI-powered Dungeons & Dragons adventure game">
<title>{% block title %}Code of Conquest{% endblock %}</title>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Lato:wght@300;400;700&display=swap" rel="stylesheet">
<!-- Main CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<!-- HTMX for dynamic interactions -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- HTMX JSON encoding extension -->
<script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js"></script>
{% block extra_head %}{% endblock %}
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-content">
<a href="/" class="logo">⚔️ Code of Conquest</a>
{% if current_user %}
<nav class="header-nav">
<span class="user-greeting">Welcome, {{ current_user.name or current_user.email }}!</span>
<form method="POST" action="{{ url_for('auth_views.logout') }}" class="logout-form">
<button type="submit" class="btn-link">Logout</button>
</form>
</nav>
{% endif %}
</div>
</header>
<!-- Main Content -->
<main>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages-container">
{% for category, message in messages %}
<div class="flash-message flash-{{ category }}">
{{ message }}
<button class="flash-close" onclick="this.parentElement.remove()">&times;</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Page Content -->
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="footer">
<p class="footer-text">
&copy; 2025 Code of Conquest. All rights reserved. | May your adventures be legendary.
</p>
</footer>
<!-- JavaScript -->
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,364 @@
{% extends "base.html" %}
{% block title %}Choose Your Class - Code of Conquest{% endblock %}
{% block content %}
<div class="creation-container">
<!-- Progress Indicator -->
<div class="creation-progress">
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">Origin</div>
</div>
<div class="progress-line"></div>
<div class="progress-step active">
<div class="step-number">2</div>
<div class="step-label">Class</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">3</div>
<div class="step-label">Customize</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">4</div>
<div class="step-label">Confirm</div>
</div>
</div>
<!-- Page Header -->
<h1 class="page-title">Choose Your Class</h1>
<p class="page-subtitle">Select your fighting style and role in combat</p>
<div class="decorative-line"></div>
<!-- Class Selection Form -->
<form method="POST" action="{{ url_for('character_views.create_class') }}" id="class-form">
<div class="class-grid">
{% for player_class in classes %}
<div class="class-card" data-class-id="{{ player_class.class_id }}">
<input
type="radio"
name="class_id"
value="{{ player_class.class_id }}"
id="class-{{ player_class.class_id }}"
class="class-radio"
required
>
<label for="class-{{ player_class.class_id }}" class="class-label">
<div class="class-header">
<h3 class="class-title">{{ player_class.name }}</h3>
</div>
<div class="class-description">
<p>{{ player_class.description }}</p>
</div>
<!-- Base Stats -->
<div class="class-stats">
<div class="stats-label">Base Stats</div>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-name">STR</span>
<span class="stat-value">{{ player_class.base_stats.strength }}</span>
</div>
<div class="stat-item">
<span class="stat-name">DEX</span>
<span class="stat-value">{{ player_class.base_stats.dexterity }}</span>
</div>
<div class="stat-item">
<span class="stat-name">CON</span>
<span class="stat-value">{{ player_class.base_stats.constitution }}</span>
</div>
<div class="stat-item">
<span class="stat-name">INT</span>
<span class="stat-value">{{ player_class.base_stats.intelligence }}</span>
</div>
<div class="stat-item">
<span class="stat-name">WIS</span>
<span class="stat-value">{{ player_class.base_stats.wisdom }}</span>
</div>
<div class="stat-item">
<span class="stat-name">CHA</span>
<span class="stat-value">{{ player_class.base_stats.charisma }}</span>
</div>
</div>
</div>
<!-- Skill Trees -->
<div class="class-trees">
<div class="trees-label">Available Specializations</div>
<p class="trees-hint">You'll choose your path as you level up</p>
<div class="tree-list">
{% for tree in player_class.skill_trees %}
<div class="tree-item">
<span class="tree-icon">🌲</span>
<span class="tree-name">{{ tree }}</span>
</div>
{% endfor %}
</div>
</div>
</label>
</div>
{% endfor %}
</div>
<!-- Navigation Buttons -->
<div class="creation-nav">
<a href="{{ url_for('character_views.create_origin') }}" class="btn btn-secondary">
← Back to Origin
</a>
<button type="submit" class="btn btn-primary">
Next: Customize →
</button>
</div>
</form>
</div>
<style>
/* ===== PROGRESS INDICATOR ===== */
.creation-progress {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
padding: 1.5rem 0;
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
color: var(--text-muted);
font-family: var(--font-heading);
font-weight: 600;
font-size: var(--text-sm);
}
.progress-step.completed .step-number {
background: var(--accent-gold);
border-color: var(--accent-gold);
color: var(--bg-primary);
}
.progress-step.active .step-number {
background: var(--bg-primary);
border-color: var(--accent-gold);
color: var(--accent-gold);
box-shadow: 0 0 10px rgba(243, 156, 18, 0.3);
}
.step-label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
font-weight: 600;
text-align: center;
}
.progress-step.active .step-label {
color: var(--accent-gold);
}
.progress-step.completed .step-label {
color: var(--text-secondary);
}
.progress-line {
width: 60px;
height: 2px;
background: var(--border-primary);
margin: 0 0.5rem;
margin-bottom: 1.5rem;
}
.progress-step.completed ~ .progress-line {
background: var(--accent-gold);
}
/* ===== CLASS GRID ===== */
.class-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
/* ===== CLASS CARDS ===== */
.class-card {
position: relative;
}
.class-radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.class-label {
display: block;
padding: 1.5rem;
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
height: 100%;
}
.class-label:hover {
border-color: var(--accent-gold);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.class-radio:checked + .class-label {
border-color: var(--accent-gold);
box-shadow: var(--shadow-glow);
background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(243, 156, 18, 0.1) 100%);
}
.class-header {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary);
}
.class-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.class-description {
margin-bottom: 1rem;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
/* ===== STATS SECTION ===== */
.class-stats {
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-input);
border-radius: 4px;
}
.stats-label {
font-family: var(--font-heading);
font-size: var(--text-sm);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.75rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
background: var(--bg-secondary);
border-radius: 4px;
border: 1px solid var(--border-primary);
}
.stat-name {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
font-weight: 600;
margin-bottom: 0.25rem;
}
.stat-value {
font-family: var(--font-heading);
font-size: var(--text-lg);
color: var(--text-primary);
font-weight: 600;
}
/* ===== SKILL TREES SECTION ===== */
.class-trees {
margin-bottom: 1rem;
}
.trees-label {
font-family: var(--font-heading);
font-size: var(--text-sm);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.trees-hint {
font-size: var(--text-xs);
color: var(--text-muted);
font-style: italic;
margin-bottom: 0.5rem;
margin-top: 0;
}
.tree-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tree-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-input);
border-radius: 4px;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.tree-icon {
font-size: var(--text-base);
}
.tree-name {
font-weight: 500;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.class-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,597 @@
{% extends "base.html" %}
{% block title %}Confirm Your Character - Code of Conquest{% endblock %}
{% block content %}
<div class="creation-container">
<!-- Progress Indicator -->
<div class="creation-progress">
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">Origin</div>
</div>
<div class="progress-line"></div>
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">Class</div>
</div>
<div class="progress-line"></div>
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">Customize</div>
</div>
<div class="progress-line"></div>
<div class="progress-step active">
<div class="step-number">4</div>
<div class="step-label">Confirm</div>
</div>
</div>
<!-- Page Header -->
<h1 class="page-title">Your Hero Awaits</h1>
<p class="page-subtitle">Review your character and begin your adventure</p>
<div class="decorative-line"></div>
<!-- Character Summary Card -->
<div class="confirm-card">
<!-- Character Header -->
<div class="character-header">
<div class="character-name-display">
<span class="name-label">Hero Name:</span>
<h2 class="character-name">{{ character_name }}</h2>
</div>
<div class="character-class-origin">
<span class="class-badge">{{ player_class.name }}</span>
<span class="origin-badge">{{ origin.name }}</span>
</div>
</div>
<div class="decorative-line"></div>
<!-- Two Column Layout -->
<div class="confirm-content">
<!-- Left Column: Character Details -->
<div class="details-column">
<!-- Origin Story -->
<div class="detail-section">
<h3 class="section-title">Your Origin</h3>
<p class="origin-story">{{ origin.description }}</p>
</div>
<!-- Starting Location -->
<div class="detail-section">
<h3 class="section-title">Starting Location</h3>
<div class="location-info">
<div class="location-name">{{ origin.starting_location.name }}</div>
<p class="location-description">{{ origin.starting_location.description }}</p>
</div>
</div>
<!-- Starting Bonus -->
{% if origin.starting_bonus %}
<div class="detail-section">
<h3 class="section-title">Starting Bonus</h3>
<div class="bonus-item">
<span class="bonus-icon"></span>
<div>
<div class="bonus-text"><strong>{{ origin.starting_bonus.trait }}</strong></div>
<div class="bonus-description">{{ origin.starting_bonus.description }}</div>
<div class="bonus-effect">Effect: {{ origin.starting_bonus.effect }}</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Right Column: Class & Stats -->
<div class="stats-column">
<!-- Class Info -->
<div class="detail-section">
<h3 class="section-title">{{ player_class.name }}</h3>
<p class="class-description">{{ player_class.description }}</p>
</div>
<!-- Base Stats -->
<div class="detail-section">
<h3 class="section-title">Base Attributes</h3>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-name">Strength</div>
<div class="stat-value">{{ player_class.base_stats.strength }}</div>
</div>
<div class="stat-box">
<div class="stat-name">Dexterity</div>
<div class="stat-value">{{ player_class.base_stats.dexterity }}</div>
</div>
<div class="stat-box">
<div class="stat-name">Constitution</div>
<div class="stat-value">{{ player_class.base_stats.constitution }}</div>
</div>
<div class="stat-box">
<div class="stat-name">Intelligence</div>
<div class="stat-value">{{ player_class.base_stats.intelligence }}</div>
</div>
<div class="stat-box">
<div class="stat-name">Wisdom</div>
<div class="stat-value">{{ player_class.base_stats.wisdom }}</div>
</div>
<div class="stat-box">
<div class="stat-name">Charisma</div>
<div class="stat-value">{{ player_class.base_stats.charisma }}</div>
</div>
</div>
</div>
<!-- Skill Trees -->
<div class="detail-section">
<h3 class="section-title">Available Specializations</h3>
<p class="spec-note">Choose your path as you level up</p>
<div class="tree-list">
{% for tree in player_class.skill_trees %}
<div class="tree-preview">
<div class="tree-header">
<span class="tree-icon">🌲</span>
<span class="tree-name">{{ tree.name if tree is mapping else tree }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Starting Equipment -->
{% if player_class.starting_equipment %}
<div class="detail-section">
<h3 class="section-title">Starting Equipment</h3>
<div class="gear-list">
{% for item_id in player_class.starting_equipment %}
<div class="gear-item">⚔️ {{ item_id|replace('_', ' ')|title }}</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<div class="decorative-line"></div>
<!-- Final Confirmation -->
<div class="confirmation-section">
<div class="warning-box">
<div class="warning-icon">⚠️</div>
<div class="warning-content">
<strong>Ready to begin?</strong>
<p>Once created, your character's class and origin cannot be changed. You can create additional characters based on your subscription tier.</p>
</div>
</div>
<form method="POST" action="{{ url_for('character_views.create_confirm') }}" id="confirm-form">
<!-- Navigation Buttons -->
<div class="creation-nav">
<a href="{{ url_for('character_views.create_customize') }}" class="btn btn-secondary">
← Back to Customize
</a>
<button type="submit" class="btn btn-primary btn-create">
Create Character & Begin Adventure ⚔️
</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* ===== PROGRESS INDICATOR ===== */
.creation-progress {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
padding: 1.5rem 0;
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
color: var(--text-muted);
font-family: var(--font-heading);
font-weight: 600;
font-size: var(--text-sm);
}
.progress-step.completed .step-number {
background: var(--accent-gold);
border-color: var(--accent-gold);
color: var(--bg-primary);
}
.progress-step.active .step-number {
background: var(--bg-primary);
border-color: var(--accent-gold);
color: var(--accent-gold);
box-shadow: 0 0 10px rgba(243, 156, 18, 0.3);
}
.step-label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
font-weight: 600;
text-align: center;
}
.progress-step.active .step-label {
color: var(--accent-gold);
}
.progress-step.completed .step-label {
color: var(--text-secondary);
}
.progress-line {
width: 60px;
height: 2px;
background: var(--border-primary);
margin: 0 0.5rem;
margin-bottom: 1.5rem;
}
.progress-step.completed ~ .progress-line {
background: var(--accent-gold);
}
/* ===== CONFIRM CARD ===== */
.confirm-card {
background: var(--bg-secondary);
border: 2px solid var(--border-ornate);
border-radius: 8px;
padding: 2.5rem;
box-shadow: var(--shadow-lg);
position: relative;
}
/* Ornate corners */
.confirm-card::before,
.confirm-card::after {
content: '';
position: absolute;
width: 40px;
height: 40px;
border: 2px solid var(--accent-gold);
}
.confirm-card::before {
top: -2px;
left: -2px;
border-right: none;
border-bottom: none;
}
.confirm-card::after {
bottom: -2px;
right: -2px;
border-left: none;
border-top: none;
}
/* ===== CHARACTER HEADER ===== */
.character-header {
text-align: center;
margin-bottom: 2rem;
}
.character-name-display {
margin-bottom: 1rem;
}
.name-label {
display: block;
font-family: var(--font-heading);
font-size: var(--text-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 0.5rem;
}
.character-name {
font-family: var(--font-heading);
font-size: var(--text-4xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 2px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
margin: 0;
}
.character-class-origin {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
}
.class-badge,
.origin-badge {
padding: 0.5rem 1.5rem;
border-radius: 20px;
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
.class-badge {
background: linear-gradient(135deg, var(--accent-gold) 0%, var(--accent-gold-hover) 100%);
color: var(--bg-primary);
}
.origin-badge {
background: var(--bg-input);
border: 1px solid var(--border-primary);
color: var(--text-primary);
}
/* ===== CONFIRM CONTENT LAYOUT ===== */
.confirm-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
/* ===== DETAIL SECTIONS ===== */
.detail-section {
margin-bottom: 2rem;
}
.detail-section:last-child {
margin-bottom: 0;
}
.section-title {
font-family: var(--font-heading);
font-size: var(--text-lg);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-primary);
}
/* ===== ORIGIN STORY ===== */
.origin-story {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 0;
}
/* ===== LOCATION INFO ===== */
.location-name {
font-size: var(--text-lg);
color: var(--text-primary);
font-weight: 600;
margin-bottom: 0.5rem;
}
.location-description {
color: var(--text-secondary);
line-height: 1.6;
}
/* ===== BONUS LIST ===== */
.bonus-list {
list-style: none;
padding: 0;
margin: 0;
}
.bonus-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-input);
border-radius: 4px;
}
.bonus-icon {
font-size: var(--text-xl);
flex-shrink: 0;
}
.bonus-text {
color: var(--text-primary);
font-weight: 500;
margin-bottom: 0.25rem;
}
.bonus-description {
color: var(--text-secondary);
font-size: var(--text-sm);
margin: 0.25rem 0;
}
.bonus-effect {
color: var(--text-muted);
font-size: var(--text-xs);
font-style: italic;
}
/* ===== CLASS INFO ===== */
.class-description {
color: var(--text-secondary);
line-height: 1.6;
}
/* ===== STATS GRID ===== */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.stat-box {
padding: 1rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
text-align: center;
}
.stat-name {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
font-weight: 600;
margin-bottom: 0.5rem;
}
.stat-value {
font-family: var(--font-heading);
font-size: var(--text-2xl);
color: var(--accent-gold);
font-weight: 700;
}
/* ===== SKILL TREES ===== */
.spec-note {
font-size: var(--text-sm);
color: var(--text-muted);
font-style: italic;
margin-bottom: 1rem;
}
.tree-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tree-preview {
padding: 1rem;
background: var(--bg-input);
border-radius: 4px;
border-left: 3px solid var(--accent-gold);
}
.tree-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.tree-icon {
font-size: var(--text-lg);
}
.tree-name {
font-weight: 600;
color: var(--text-primary);
}
.tree-description {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.5;
}
/* ===== STARTING GEAR ===== */
.gear-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gear-item {
padding: 0.75rem;
background: var(--bg-input);
border-radius: 4px;
color: var(--text-primary);
}
/* ===== WARNING BOX ===== */
.confirmation-section {
margin-top: 2rem;
}
.warning-box {
display: flex;
gap: 1rem;
padding: 1.5rem;
background: rgba(243, 156, 18, 0.1);
border: 2px solid var(--accent-gold);
border-radius: 4px;
margin-bottom: 2rem;
}
.warning-icon {
font-size: var(--text-3xl);
flex-shrink: 0;
}
.warning-content {
font-size: var(--text-sm);
color: var(--text-primary);
line-height: 1.6;
}
.warning-content strong {
display: block;
font-size: var(--text-base);
color: var(--accent-gold);
margin-bottom: 0.5rem;
}
/* ===== CREATE BUTTON ===== */
.btn-create {
font-size: var(--text-lg);
padding: 1rem 2rem;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.confirm-card {
padding: 1.5rem;
}
.character-name {
font-size: var(--text-2xl);
}
.confirm-content {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
.character-class-origin {
flex-direction: column;
gap: 0.5rem;
}
.warning-box {
flex-direction: column;
text-align: center;
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,425 @@
{% extends "base.html" %}
{% block title %}Customize Your Character - Code of Conquest{% endblock %}
{% block content %}
<div class="creation-container">
<!-- Progress Indicator -->
<div class="creation-progress">
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">Origin</div>
</div>
<div class="progress-line"></div>
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">Class</div>
</div>
<div class="progress-line"></div>
<div class="progress-step active">
<div class="step-number">3</div>
<div class="step-label">Customize</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">4</div>
<div class="step-label">Confirm</div>
</div>
</div>
<!-- Page Header -->
<h1 class="page-title">Name Your Hero</h1>
<p class="page-subtitle">What shall they call you in the halls of legend?</p>
<div class="decorative-line"></div>
<!-- Customization Form -->
<form method="POST" action="{{ url_for('character_views.create_customize') }}" id="customize-form">
<div class="customize-content">
<!-- Character Summary Panel -->
<div class="summary-panel">
<h3 class="panel-title">Your Character So Far</h3>
<div class="summary-section">
<div class="summary-label">Origin</div>
<div class="summary-value">{{ origin.name }}</div>
<p class="summary-description">{{ origin.description[:100] }}...</p>
</div>
<div class="summary-section">
<div class="summary-label">Class</div>
<div class="summary-value">{{ player_class.name }}</div>
<p class="summary-description">{{ player_class.description[:100] }}...</p>
</div>
<div class="summary-section">
<div class="summary-label">Starting Location</div>
<div class="summary-value">{{ origin.starting_location.name }}</div>
<p class="summary-description">{{ origin.starting_location.description }}</p>
</div>
<div class="summary-section">
<div class="summary-label">Base Stats</div>
<div class="stats-compact">
<span class="stat-compact">STR {{ player_class.base_stats.strength }}</span>
<span class="stat-compact">DEX {{ player_class.base_stats.dexterity }}</span>
<span class="stat-compact">CON {{ player_class.base_stats.constitution }}</span>
<span class="stat-compact">INT {{ player_class.base_stats.intelligence }}</span>
<span class="stat-compact">WIS {{ player_class.base_stats.wisdom }}</span>
<span class="stat-compact">CHA {{ player_class.base_stats.charisma }}</span>
</div>
</div>
</div>
<!-- Name Input Panel -->
<div class="name-panel">
<h3 class="panel-title">Choose Your Name</h3>
<div class="form-group">
<label class="form-label" for="character-name">Character Name</label>
<input
type="text"
id="character-name"
name="name"
class="form-input"
placeholder="Enter your character's name"
minlength="3"
maxlength="30"
required
autofocus
>
<span class="form-help">3-30 characters</span>
</div>
<div class="name-suggestions">
<p class="suggestions-label">Need inspiration? Try these:</p>
<div class="suggestion-buttons">
<button type="button" class="suggestion-btn" onclick="setName('Aldric the Brave')">Aldric the Brave</button>
<button type="button" class="suggestion-btn" onclick="setName('Lyra Shadowstep')">Lyra Shadowstep</button>
<button type="button" class="suggestion-btn" onclick="setName('Theron Ironheart')">Theron Ironheart</button>
<button type="button" class="suggestion-btn" onclick="setName('Mira Stormborn')">Mira Stormborn</button>
<button type="button" class="suggestion-btn" onclick="setName('Kael Nightwhisper')">Kael Nightwhisper</button>
<button type="button" class="suggestion-btn" onclick="setName('Aria Firehand')">Aria Firehand</button>
</div>
</div>
<div class="info-box">
<div class="info-icon"></div>
<div class="info-content">
<strong>Character Creation Tips:</strong>
<ul>
<li>Your name will be visible to other players</li>
<li>Choose a name that fits the fantasy RPG setting</li>
<li>You can always create more characters later</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Navigation Buttons -->
<div class="creation-nav">
<a href="{{ url_for('character_views.create_class') }}" class="btn btn-secondary">
← Back to Class
</a>
<button type="submit" class="btn btn-primary">
Next: Confirm →
</button>
</div>
</form>
</div>
<style>
/* ===== PROGRESS INDICATOR ===== */
.creation-progress {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
padding: 1.5rem 0;
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
color: var(--text-muted);
font-family: var(--font-heading);
font-weight: 600;
font-size: var(--text-sm);
}
.progress-step.completed .step-number {
background: var(--accent-gold);
border-color: var(--accent-gold);
color: var(--bg-primary);
}
.progress-step.active .step-number {
background: var(--bg-primary);
border-color: var(--accent-gold);
color: var(--accent-gold);
box-shadow: 0 0 10px rgba(243, 156, 18, 0.3);
}
.step-label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
font-weight: 600;
text-align: center;
}
.progress-step.active .step-label {
color: var(--accent-gold);
}
.progress-step.completed .step-label {
color: var(--text-secondary);
}
.progress-line {
width: 60px;
height: 2px;
background: var(--border-primary);
margin: 0 0.5rem;
margin-bottom: 1.5rem;
}
.progress-step.completed ~ .progress-line {
background: var(--accent-gold);
}
/* ===== CUSTOMIZE CONTENT LAYOUT ===== */
.customize-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
/* ===== PANELS ===== */
.summary-panel,
.name-panel {
background: var(--bg-secondary);
border: 2px solid var(--border-ornate);
border-radius: 8px;
padding: 2rem;
box-shadow: var(--shadow-md);
}
.panel-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--border-primary);
}
/* ===== SUMMARY SECTIONS ===== */
.summary-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-primary);
}
.summary-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.summary-label {
font-family: var(--font-heading);
font-size: var(--text-sm);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
}
.summary-value {
font-size: var(--text-lg);
color: var(--text-primary);
font-weight: 600;
margin-bottom: 0.5rem;
}
.summary-description {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.5;
}
/* ===== COMPACT STATS ===== */
.stats-compact {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.stat-compact {
padding: 0.5rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
}
/* ===== NAME INPUT ===== */
.form-group {
margin-bottom: 2rem;
}
.form-label {
display: block;
font-family: var(--font-heading);
font-size: var(--text-sm);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-base);
transition: all 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: var(--accent-gold);
box-shadow: 0 0 10px rgba(243, 156, 18, 0.2);
}
.form-help {
display: block;
margin-top: 0.25rem;
font-size: var(--text-xs);
color: var(--text-muted);
}
/* ===== NAME SUGGESTIONS ===== */
.name-suggestions {
margin-bottom: 2rem;
}
.suggestions-label {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.suggestion-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.suggestion-btn {
padding: 0.5rem 1rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-secondary);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: all 0.3s ease;
}
.suggestion-btn:hover {
border-color: var(--accent-gold);
color: var(--accent-gold);
background: rgba(243, 156, 18, 0.1);
}
/* ===== INFO BOX ===== */
.info-box {
display: flex;
gap: 1rem;
padding: 1rem;
background: rgba(52, 152, 219, 0.1);
border-left: 4px solid var(--accent-blue);
border-radius: 4px;
}
.info-icon {
font-size: var(--text-2xl);
flex-shrink: 0;
}
.info-content {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.info-content strong {
color: var(--text-primary);
display: block;
margin-bottom: 0.5rem;
}
.info-content ul {
margin: 0;
padding-left: 1.5rem;
}
.info-content li {
margin-bottom: 0.25rem;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.customize-content {
grid-template-columns: 1fr;
}
.suggestion-buttons {
grid-template-columns: 1fr;
}
.stats-compact {
gap: 0.5rem;
}
.stat-compact {
font-size: var(--text-xs);
padding: 0.4rem 0.6rem;
}
}
</style>
<script>
function setName(name) {
document.getElementById('character-name').value = name;
document.getElementById('character-name').focus();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,387 @@
{% extends "base.html" %}
{% block title %}Choose Your Origin - Code of Conquest{% endblock %}
{% block content %}
<div class="creation-container">
<!-- Progress Indicator -->
<div class="creation-progress">
<div class="progress-step active">
<div class="step-number">1</div>
<div class="step-label">Origin</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">2</div>
<div class="step-label">Class</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">3</div>
<div class="step-label">Customize</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">4</div>
<div class="step-label">Confirm</div>
</div>
</div>
<!-- Page Header -->
<h1 class="page-title">Choose Your Origin</h1>
<p class="page-subtitle">Every hero has a beginning. Where does your story start?</p>
<div class="decorative-line"></div>
<!-- Origin Selection Form -->
<form method="POST" action="{{ url_for('character_views.create_origin') }}" id="origin-form">
<div class="origin-grid">
{% for origin in origins %}
<div class="origin-card" data-origin-id="{{ origin.id }}">
<input
type="radio"
name="origin_id"
value="{{ origin.id }}"
id="origin-{{ origin.id }}"
class="origin-radio"
required
>
<label for="origin-{{ origin.id }}" class="origin-label">
<div class="origin-header">
<h3 class="origin-title">{{ origin.name }}</h3>
</div>
<div class="origin-description">
<p class="description-preview">
{{ origin.description[:180] }}{% if origin.description|length > 180 %}...{% endif %}
</p>
{% if origin.description|length > 180 %}
<div class="description-full" style="display: none;">
<p>{{ origin.description }}</p>
</div>
<button type="button" class="expand-btn" onclick="toggleDescription(this)">
<span class="expand-text">Read More</span>
<span class="expand-icon"></span>
</button>
{% endif %}
</div>
<div class="origin-details">
<div class="detail-item">
<span class="detail-label">Starting Location:</span>
<span class="detail-value">{{ origin.starting_location.name }}</span>
</div>
{% if origin.starting_bonus %}
<div class="detail-item">
<span class="detail-label">Starting Bonus:</span>
<div class="bonus-display">
<strong>{{ origin.starting_bonus.trait }}</strong>
<p class="bonus-description">{{ origin.starting_bonus.description }}</p>
</div>
</div>
{% endif %}
</div>
</label>
</div>
{% endfor %}
</div>
<!-- Navigation Buttons -->
<div class="creation-nav">
<a href="{{ url_for('character_views.list_characters') }}" class="btn btn-secondary">
Cancel
</a>
<button type="submit" class="btn btn-primary">
Next: Choose Class →
</button>
</div>
</form>
</div>
<style>
/* ===== CHARACTER CREATION CONTAINER ===== */
.creation-container {
max-width: 1200px;
margin: 2rem auto;
padding: 2rem;
}
/* ===== PROGRESS INDICATOR ===== */
.creation-progress {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 3rem;
gap: 1rem;
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-input);
border: 2px solid var(--border-primary);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-heading);
font-weight: 600;
color: var(--text-muted);
transition: all 0.3s ease;
}
.step-label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.3s ease;
}
.progress-step.active .step-number {
background: var(--accent-gold);
border-color: var(--accent-gold);
color: var(--bg-primary);
box-shadow: var(--shadow-glow);
}
.progress-step.active .step-label {
color: var(--accent-gold);
font-weight: 600;
}
.progress-step.completed .step-number {
background: var(--accent-green);
border-color: var(--accent-green);
color: var(--bg-primary);
}
.progress-step.completed .step-label {
color: var(--accent-green);
}
.progress-line {
width: 60px;
height: 2px;
background: var(--border-primary);
}
/* ===== ORIGIN GRID ===== */
.origin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
/* ===== ORIGIN CARDS ===== */
.origin-card {
position: relative;
}
.origin-radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.origin-label {
display: block;
padding: 2rem;
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
height: 100%;
}
.origin-label:hover {
border-color: var(--accent-gold);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.origin-radio:checked + .origin-label {
border-color: var(--accent-gold);
box-shadow: var(--shadow-glow);
background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(243, 156, 18, 0.1) 100%);
}
.origin-header {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary);
}
.origin-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
}
.origin-description {
margin-bottom: 1rem;
color: var(--text-secondary);
line-height: 1.6;
}
.description-preview {
margin: 0;
}
.description-full {
margin-top: 0.5rem;
}
.description-full p {
margin: 0;
}
.expand-btn {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0;
background: none;
border: none;
color: var(--accent-gold);
font-size: var(--text-sm);
cursor: pointer;
transition: all 0.3s ease;
}
.expand-btn:hover {
color: var(--accent-gold-hover);
}
.expand-icon {
font-size: var(--text-xs);
transition: transform 0.3s ease;
}
.expand-btn.expanded .expand-icon {
transform: rotate(180deg);
}
.origin-details {
margin-bottom: 1.5rem;
}
.detail-item {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.detail-label {
font-family: var(--font-heading);
font-size: var(--text-sm);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.detail-value {
color: var(--text-primary);
font-weight: 500;
}
.bonus-display {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--bg-input);
border-radius: 4px;
}
.bonus-display strong {
color: var(--accent-gold);
display: block;
margin-bottom: 0.25rem;
}
.bonus-description {
color: var(--text-secondary);
font-size: var(--text-sm);
margin: 0.25rem 0;
}
/* ===== CREATION NAVIGATION ===== */
.creation-nav {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 2rem;
}
.creation-nav .btn {
min-width: 200px;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.creation-container {
padding: 1rem;
}
.origin-grid {
grid-template-columns: 1fr;
}
.creation-progress {
gap: 0.5rem;
}
.progress-line {
width: 30px;
}
.step-label {
display: none;
}
.creation-nav {
flex-direction: column;
}
.creation-nav .btn {
width: 100%;
}
}
</style>
<script>
function toggleDescription(button) {
const card = button.closest('.origin-card');
const preview = card.querySelector('.description-preview');
const full = card.querySelector('.description-full');
const expandText = button.querySelector('.expand-text');
if (full.style.display === 'none') {
// Expand
preview.style.display = 'none';
full.style.display = 'block';
expandText.textContent = 'Read Less';
button.classList.add('expanded');
} else {
// Collapse
preview.style.display = 'block';
full.style.display = 'none';
expandText.textContent = 'Read More';
button.classList.remove('expanded');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,485 @@
{% extends "base.html" %}
{% block title %}{{ character.name }} - Code of Conquest{% endblock %}
{% block content %}
<div class="character-detail-container">
<!-- Header -->
<div class="detail-header">
<div class="header-left">
<h1 class="character-name">{{ character.name }}</h1>
<p class="character-subtitle">
Level {{ character.level }} {{ character.player_class.name }}
</p>
</div>
<div class="header-right">
<a href="{{ url_for('character_views.list_characters') }}" class="btn btn-secondary">
← Back to Characters
</a>
</div>
</div>
<div class="decorative-line"></div>
<!-- Main Content Grid -->
<div class="detail-grid">
<!-- Left Column: Stats & Info -->
<div class="detail-section">
<h2 class="section-title">Character Information</h2>
<div class="info-card">
<div class="info-row">
<span class="info-label">Class:</span>
<span class="info-value">{{ character.player_class.name }}</span>
</div>
<div class="info-row">
<span class="info-label">Origin:</span>
<span class="info-value">{{ character.origin_name }}</span>
</div>
<div class="info-row">
<span class="info-label">Level:</span>
<span class="info-value">{{ character.level }}</span>
</div>
<div class="info-row">
<span class="info-label">Experience:</span>
<span class="info-value">{{ character.experience }} XP</span>
</div>
<div class="info-row">
<span class="info-label">Gold:</span>
<span class="info-value gold">{{ character.gold }} 💰</span>
</div>
<div class="info-row">
<span class="info-label">Skill Points:</span>
<span class="info-value">{{ character.available_skill_points }}</span>
</div>
</div>
<!-- Base Stats -->
<h3 class="subsection-title">Base Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-name">STR</span>
<span class="stat-value">{{ character.base_stats.strength }}</span>
</div>
<div class="stat-item">
<span class="stat-name">DEX</span>
<span class="stat-value">{{ character.base_stats.dexterity }}</span>
</div>
<div class="stat-item">
<span class="stat-name">CON</span>
<span class="stat-value">{{ character.base_stats.constitution }}</span>
</div>
<div class="stat-item">
<span class="stat-name">INT</span>
<span class="stat-value">{{ character.base_stats.intelligence }}</span>
</div>
<div class="stat-item">
<span class="stat-name">WIS</span>
<span class="stat-value">{{ character.base_stats.wisdom }}</span>
</div>
<div class="stat-item">
<span class="stat-name">CHA</span>
<span class="stat-value">{{ character.base_stats.charisma }}</span>
</div>
</div>
<!-- Derived Stats -->
<h3 class="subsection-title">Derived Statistics</h3>
<div class="info-card">
<div class="info-row">
<span class="info-label">Hit Points:</span>
<span class="info-value">{{ character.current_hp }} / {{ character.max_hp }}</span>
</div>
<div class="info-row">
<span class="info-label">Mana Points:</span>
<span class="info-value">{{ character.base_stats.mana_points }}</span>
</div>
<div class="info-row">
<span class="info-label">Defense:</span>
<span class="info-value">{{ character.base_stats.defense }}</span>
</div>
<div class="info-row">
<span class="info-label">Resistance:</span>
<span class="info-value">{{ character.base_stats.resistance }}</span>
</div>
</div>
</div>
<!-- Right Column: Skills, Inventory, etc -->
<div class="detail-section">
<!-- Unlocked Skills -->
<h2 class="section-title">Unlocked Skills</h2>
{% if character.unlocked_skills %}
<div class="skills-list">
{% for skill_id in character.unlocked_skills %}
<div class="skill-badge">{{ skill_id }}</div>
{% endfor %}
</div>
{% else %}
<p class="empty-text">No skills unlocked yet.</p>
{% endif %}
<a href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}" class="btn btn-primary btn-block">
Manage Skills
</a>
<!-- Equipment -->
<h2 class="section-title">Equipment</h2>
{% if character.equipped %}
<div class="equipment-list">
{% for slot, item in character.equipped.items() %}
<div class="equipment-item">
<span class="equipment-slot">{{ slot|title }}:</span>
<span class="equipment-name">{{ item.name }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-text">No equipment equipped.</p>
{% endif %}
<!-- Inventory -->
<h2 class="section-title">Inventory</h2>
{% if character.inventory %}
<div class="inventory-list">
{% for item in character.inventory %}
<div class="inventory-item">
<span class="item-name">{{ item.name }}</span>
<span class="item-type">{{ item.item_type }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-text">Inventory is empty.</p>
{% endif %}
<!-- Active Quests -->
<h2 class="section-title">Active Quests</h2>
{% if character.active_quests %}
<div class="quests-list">
{% for quest_id in character.active_quests %}
<div class="quest-item">{{ quest_id }}</div>
{% endfor %}
</div>
{% else %}
<p class="empty-text">No active quests.</p>
{% endif %}
</div>
</div>
<!-- Origin Story -->
<div class="origin-story-section">
<h2 class="section-title">Origin: {{ character.origin.name }}</h2>
<p class="origin-description">{{ character.origin.description }}</p>
<div class="starting-location">
<h3 class="subsection-title">Starting Location</h3>
<p><strong>{{ character.origin.starting_location.name }}</strong> ({{ character.origin.starting_location.region }})</p>
<p class="location-description">{{ character.origin.starting_location.description }}</p>
</div>
</div>
<!-- Actions -->
<div class="character-actions">
<form method="POST" action="{{ url_for('character_views.delete_character', character_id=character.character_id) }}"
onsubmit="return confirm('Are you sure you want to delete {{ character.name }}? This cannot be undone.');">
<button type="submit" class="btn btn-danger">
Delete Character
</button>
</form>
</div>
</div>
<style>
/* ===== CHARACTER DETAIL CONTAINER ===== */
.character-detail-container {
max-width: 1400px;
margin: 2rem auto;
padding: 2rem;
}
/* ===== HEADER ===== */
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 2rem;
}
.character-name {
font-family: var(--font-heading);
font-size: var(--text-3xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 2px;
margin: 0 0 0.5rem 0;
}
.character-subtitle {
font-size: var(--text-lg);
color: var(--text-secondary);
margin: 0;
}
/* ===== GRID LAYOUT ===== */
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.detail-section {
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
border-radius: 8px;
padding: 1.5rem;
}
/* ===== SECTION TITLES ===== */
.section-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-primary);
}
.subsection-title {
font-family: var(--font-heading);
font-size: var(--text-lg);
color: var(--text-primary);
margin: 1.5rem 0 1rem 0;
text-transform: uppercase;
letter-spacing: 1px;
}
/* ===== INFO CARD ===== */
.info-card {
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--bg-secondary);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: var(--text-sm);
color: var(--text-muted);
font-weight: 600;
}
.info-value {
font-size: var(--text-sm);
color: var(--text-primary);
}
.info-value.gold {
color: var(--accent-gold);
font-weight: 600;
}
/* ===== STATS GRID ===== */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-item {
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 1rem;
text-align: center;
}
.stat-name {
display: block;
font-size: var(--text-xs);
color: var(--text-muted);
font-weight: 600;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.stat-value {
display: block;
font-size: var(--text-2xl);
color: var(--accent-gold);
font-weight: 700;
}
/* ===== SKILLS ===== */
.skills-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.skill-badge {
background: var(--bg-input);
border: 1px solid var(--accent-gold);
border-radius: 12px;
padding: 0.25rem 0.75rem;
font-size: var(--text-xs);
color: var(--accent-gold);
text-transform: uppercase;
font-weight: 600;
}
/* ===== EQUIPMENT & INVENTORY ===== */
.equipment-list,
.inventory-list {
margin-bottom: 1rem;
}
.equipment-item,
.inventory-item {
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.equipment-slot,
.item-type {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
font-weight: 600;
}
.equipment-name,
.item-name {
color: var(--text-primary);
}
/* ===== QUESTS ===== */
.quests-list {
margin-bottom: 1rem;
}
.quest-item {
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
/* ===== ORIGIN STORY ===== */
.origin-story-section {
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.origin-description {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 1.5rem;
}
.narrative-hooks {
list-style-type: none;
padding: 0;
margin-bottom: 1.5rem;
}
.narrative-hooks li {
background: var(--bg-input);
border-left: 3px solid var(--accent-gold);
padding: 0.75rem;
margin-bottom: 0.5rem;
color: var(--text-secondary);
}
.starting-location {
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 1rem;
}
.location-description {
color: var(--text-secondary);
font-size: var(--text-sm);
margin: 0.5rem 0 0 0;
}
/* ===== EMPTY STATE ===== */
.empty-text {
color: var(--text-muted);
font-style: italic;
margin: 0.5rem 0 1rem 0;
}
/* ===== BUTTONS ===== */
.btn-block {
display: block;
width: 100%;
margin-bottom: 1.5rem;
}
.character-actions {
text-align: center;
}
.btn-danger {
background: transparent;
border: 2px solid var(--accent-red);
color: var(--accent-red);
}
.btn-danger:hover {
background: var(--accent-red);
color: var(--text-primary);
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.character-detail-container {
padding: 1rem;
}
.detail-header {
flex-direction: column;
align-items: flex-start;
}
.detail-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,455 @@
{% extends "base.html" %}
{% block title %}Your Characters - Code of Conquest{% endblock %}
{% block content %}
<div class="characters-container">
<div class="characters-header">
<div class="header-left">
<h1 class="page-title">Your Characters</h1>
<p class="page-subtitle">
{{ characters|length }} of {{ max_characters }} characters
<span class="tier-badge">{{ current_tier|upper }}</span>
</p>
</div>
<div class="header-right">
{% if can_create %}
<a href="{{ url_for('character_views.create_origin') }}" class="btn btn-primary">
⚔️ Create New Character
</a>
{% else %}
<button class="btn btn-primary" disabled title="Character limit reached for {{ current_tier }} tier">
Character Limit Reached
</button>
{% endif %}
</div>
</div>
<div class="decorative-line"></div>
{% if characters %}
<div class="characters-grid">
{% for character in characters %}
<div class="character-card">
<div class="character-card-header">
<h3 class="character-name">{{ character.name }}</h3>
<span class="character-level">Level {{ character.level }}</span>
</div>
<div class="character-info">
<div class="info-row">
<span class="info-label">Class:</span>
<span class="info-value">{{ character.class_name or character.class|title }}</span>
</div>
<div class="info-row">
<span class="info-label">Origin:</span>
<span class="info-value">{{ character.origin|replace('_', ' ')|title }}</span>
</div>
<div class="info-row">
<span class="info-label">Gold:</span>
<span class="info-value gold">{{ character.gold }} 💰</span>
</div>
<div class="info-row">
<span class="info-label">Experience:</span>
<span class="info-value">{{ character.experience }}</span>
</div>
</div>
{# Sessions Section #}
<div class="character-sessions">
{% if character.sessions %}
<div class="sessions-header">
<span class="sessions-label">Active Sessions:</span>
</div>
<div class="sessions-list">
{% for sess in character.sessions[:3] %}
<a href="{{ url_for('game.play_session', session_id=sess.session_id) }}" class="session-link">
<span class="session-turn">Turn {{ sess.turn_number or 0 }}</span>
<span class="session-status {{ sess.status or 'active' }}">{{ sess.status or 'active' }}</span>
</a>
{% endfor %}
{% if character.sessions|length > 3 %}
<span class="sessions-more">+{{ character.sessions|length - 3 }} more</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="character-actions">
{# Primary Play Action #}
{% if character.sessions %}
<a href="{{ url_for('game.play_session', session_id=character.sessions[0].session_id) }}" class="btn btn-primary btn-sm">
Continue Playing
</a>
<form method="POST" action="{{ url_for('character_views.create_session', character_id=character.character_id) }}" style="display: inline;">
<button type="submit" class="btn btn-secondary btn-sm" title="Start a new adventure">
New Session
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('character_views.create_session', character_id=character.character_id) }}" style="display: inline;">
<button type="submit" class="btn btn-primary btn-sm">
Start Adventure
</button>
</form>
{% endif %}
{# Secondary Actions #}
<a href="{{ url_for('character_views.view_character', character_id=character.character_id) }}" class="btn btn-secondary btn-sm">
Details
</a>
<a href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}" class="btn btn-secondary btn-sm">
Skills
</a>
<form method="POST" action="{{ url_for('character_views.delete_character', character_id=character.character_id) }}" onsubmit="return confirm('Are you sure you want to delete {{ character.name }}? This cannot be undone.');" style="display: inline;">
<button type="submit" class="btn btn-danger btn-sm">
Delete
</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">⚔️</div>
<h2 class="empty-title">No Characters Yet</h2>
<p class="empty-message">Begin your adventure by creating your first character!</p>
<a href="{{ url_for('character_views.create_origin') }}" class="btn btn-primary btn-lg">
Create Your First Character
</a>
</div>
{% endif %}
</div>
<style>
/* ===== CHARACTERS CONTAINER ===== */
.characters-container {
max-width: 1400px;
margin: 2rem auto;
padding: 2rem;
}
/* ===== HEADER ===== */
.characters-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 2rem;
}
.header-left {
flex: 1;
}
.page-subtitle {
display: flex;
align-items: center;
gap: 1rem;
}
.tier-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--accent-gold);
color: var(--bg-primary);
border-radius: 12px;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
/* ===== CHARACTERS GRID ===== */
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
/* ===== CHARACTER CARDS ===== */
.character-card {
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
border-radius: 8px;
padding: 1.5rem;
transition: all 0.3s ease;
}
.character-card:hover {
border-color: var(--accent-gold);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.character-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--border-primary);
}
.character-name {
font-family: var(--font-heading);
font-size: var(--text-xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin: 0;
}
.character-level {
padding: 0.25rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 12px;
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
/* ===== CHARACTER INFO ===== */
.character-info {
margin-bottom: 1rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--bg-input);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: var(--text-sm);
color: var(--text-muted);
font-weight: 600;
}
.info-value {
font-size: var(--text-sm);
color: var(--text-primary);
}
.info-value.gold {
color: var(--accent-gold);
font-weight: 600;
}
/* ===== STATS COMPACT ===== */
.character-stats-compact {
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-input);
border-radius: 4px;
}
.stat-mini {
display: flex;
align-items: center;
gap: 0.75rem;
}
.stat-label {
font-size: var(--text-sm);
color: var(--text-muted);
font-weight: 600;
min-width: 30px;
}
.stat-bar {
flex: 1;
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-primary);
}
.stat-fill {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent-red-light) 0%, var(--accent-green) 100%);
transition: width 0.3s ease;
}
.stat-text {
font-size: var(--text-xs);
color: var(--text-secondary);
font-family: var(--font-mono);
min-width: 60px;
text-align: right;
}
/* ===== CHARACTER SESSIONS ===== */
.character-sessions {
margin-bottom: 1rem;
min-height: 1rem;
}
.sessions-header {
margin-bottom: 0.5rem;
}
.sessions-label {
font-size: var(--text-sm);
color: var(--text-muted);
font-weight: 600;
}
.sessions-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.session-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
text-decoration: none;
transition: all 0.2s ease;
}
.session-link:hover {
border-color: var(--accent-gold);
background: var(--bg-secondary);
}
.session-turn {
font-size: var(--text-xs);
color: var(--text-primary);
font-weight: 600;
}
.session-status {
font-size: var(--text-xs);
padding: 0.15rem 0.4rem;
border-radius: 3px;
text-transform: uppercase;
font-weight: 600;
}
.session-status.active {
background: var(--accent-green);
color: var(--bg-primary);
}
.session-status.paused {
background: var(--accent-gold);
color: var(--bg-primary);
}
.session-status.ended {
background: var(--text-muted);
color: var(--bg-primary);
}
.sessions-more {
font-size: var(--text-xs);
color: var(--text-muted);
padding: 0.35rem 0.5rem;
}
/* ===== CHARACTER ACTIONS ===== */
.character-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: var(--text-sm);
}
.btn-danger {
background: transparent;
border: 2px solid var(--accent-red);
color: var(--accent-red);
}
.btn-danger:hover {
background: var(--accent-red);
color: var(--text-primary);
}
/* ===== EMPTY STATE ===== */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-secondary);
border: 2px dashed var(--border-primary);
border-radius: 8px;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-title {
font-family: var(--font-heading);
font-size: var(--text-2xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 1rem;
}
.empty-message {
font-size: var(--text-lg);
color: var(--text-secondary);
margin-bottom: 2rem;
}
.btn-lg {
padding: 1rem 2rem;
font-size: var(--text-lg);
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.characters-container {
padding: 1rem;
}
.characters-header {
flex-direction: column;
align-items: flex-start;
}
.header-right {
width: 100%;
}
.header-right .btn {
width: 100%;
}
.characters-grid {
grid-template-columns: 1fr;
}
.character-actions {
flex-direction: column;
}
.character-actions .btn {
width: 100%;
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Dev Tools - Code of Conquest{% endblock %}
{% block extra_head %}
<style>
.dev-banner {
background: #dc2626;
color: white;
padding: 0.5rem 1rem;
text-align: center;
font-weight: bold;
position: sticky;
top: 0;
z-index: 100;
}
.dev-container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.dev-section {
background: rgba(30, 30, 40, 0.9);
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.dev-section h2 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
border-bottom: 1px solid #4a4a5a;
padding-bottom: 0.5rem;
}
.dev-link {
display: block;
background: #3b82f6;
color: white;
padding: 1rem 1.5rem;
border-radius: 6px;
text-decoration: none;
margin-bottom: 0.5rem;
transition: background 0.2s;
}
.dev-link:hover {
background: #2563eb;
}
.dev-link-disabled {
background: #4a4a5a;
cursor: not-allowed;
opacity: 0.6;
}
.dev-link small {
display: block;
font-size: 0.85rem;
opacity: 0.8;
margin-top: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="dev-banner">
DEV MODE - Testing Tools (Not available in production)
</div>
<div class="dev-container">
<h1>Development Testing Tools</h1>
<div class="dev-section">
<h2>Story System</h2>
<a href="{{ url_for('dev.story_hub') }}" class="dev-link">
Story Gameplay Tester
<small>Create sessions, test actions, view AI responses</small>
</a>
</div>
<div class="dev-section">
<h2>Quest System</h2>
<span class="dev-link dev-link-disabled">
Quest Tester (Coming Soon)
<small>Test quest offering, acceptance, and completion</small>
</span>
</div>
<div class="dev-section">
<h2>API Debug</h2>
<span class="dev-link dev-link-disabled">
API Inspector (Coming Soon)
<small>View raw API requests and responses</small>
</span>
</div>
<div class="dev-section">
<h2>Quick Links</h2>
<p style="color: #9ca3af; margin: 0;">
<strong>API Docs:</strong> <a href="http://localhost:5000/api/v1/docs" target="_blank" style="color: #60a5fa;">localhost:5000/api/v1/docs</a><br>
<strong>Characters:</strong> <a href="{{ url_for('character_views.list_characters') }}" style="color: #60a5fa;">Character List</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{# DM response after job completes #}
<div class="dm-response-content">
{{ dm_response | safe }}
</div>
{# Debug info #}
<div class="debug-panel" style="margin-top: 1rem; padding: 0.5rem; font-size: 0.7rem;">
<div class="debug-toggle" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
[+] Raw API Response
</div>
<div style="display: none; margin-top: 0.5rem; white-space: pre; color: #a3e635; max-height: 150px; overflow: auto;">
{{ raw_result | tojson(indent=2) }}
</div>
</div>
{# Trigger state and history refresh #}
<div hx-get="{{ url_for('dev.get_state', session_id=session_id) }}"
hx-trigger="load"
hx-target="#state-content"
hx-swap="innerHTML"
style="display: none;"></div>
<div hx-get="{{ url_for('dev.get_history', session_id=session_id) }}"
hx-trigger="load"
hx-target="#history-content"
hx-swap="innerHTML"
style="display: none;"></div>

View File

@@ -0,0 +1,21 @@
{# History entries partial #}
{% if history %}
{% for entry in history|reverse %}
<div class="history-entry">
<div class="history-turn">Turn {{ entry.turn }}</div>
<div class="history-action">{{ entry.action }}</div>
<div class="history-response">{{ entry.dm_response[:150] }}{% if entry.dm_response|length > 150 %}...{% endif %}</div>
</div>
{% endfor %}
{% if pagination and pagination.has_more %}
<button hx-get="{{ url_for('dev.get_history', session_id=session_id) }}?offset={{ pagination.offset + pagination.limit }}"
hx-target="#history-content"
hx-swap="innerHTML"
style="width: 100%; padding: 0.5rem; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
Load More
</button>
{% endif %}
{% else %}
<p style="color: #9ca3af; text-align: center; font-size: 0.85rem;">No history yet.</p>
{% endif %}

View File

@@ -0,0 +1,29 @@
{# Job status polling partial - polls every 2 seconds until complete #}
<div class="loading"
hx-get="{{ url_for('dev.job_status', job_id=job_id) }}?session_id={{ session_id }}"
hx-trigger="load delay:2s"
hx-swap="outerHTML">
<div style="margin-bottom: 0.5rem;">
<span class="spinner"></span>
Processing your action...
</div>
<div style="font-size: 0.75rem; color: #9ca3af;">
Job: {{ job_id[:8] }}... | Status: {{ status }}
</div>
</div>
<style>
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #4a4a5a;
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,129 @@
{#
NPC Dialogue partial - displays conversation history with current exchange.
Expected context:
- npc_name: Name of the NPC
- character_name: Name of the player character
- conversation_history: List of previous exchanges [{player_line, npc_response}, ...]
- player_line: What the player just said
- dialogue: NPC's current response
- session_id: For any follow-up actions
#}
<div class="npc-conversation">
<div class="npc-conversation-header">
<span class="npc-conversation-title">Conversation with {{ npc_name }}</span>
</div>
<div class="npc-conversation-history">
{# Show previous exchanges #}
{% if conversation_history %}
{% for exchange in conversation_history %}
<div class="dialogue-exchange dialogue-exchange-past">
<div class="dialogue-player">
<span class="dialogue-speaker">{{ character_name }}:</span>
<span class="dialogue-text">{{ exchange.player_line }}</span>
</div>
<div class="dialogue-npc">
<span class="dialogue-speaker">{{ npc_name }}:</span>
<span class="dialogue-text">{{ exchange.npc_response }}</span>
</div>
</div>
{% endfor %}
{% endif %}
{# Show current exchange (highlighted) #}
<div class="dialogue-exchange dialogue-exchange-current">
<div class="dialogue-player">
<span class="dialogue-speaker">{{ character_name }}:</span>
<span class="dialogue-text">{{ player_line }}</span>
</div>
<div class="dialogue-npc">
<span class="dialogue-speaker">{{ npc_name }}:</span>
<span class="dialogue-text">{{ dialogue }}</span>
</div>
</div>
</div>
</div>
<style>
.npc-conversation {
background: #1a1a2a;
border-radius: 6px;
overflow: hidden;
}
.npc-conversation-header {
background: #2a2a3a;
padding: 0.75rem 1rem;
border-bottom: 1px solid #4a4a5a;
}
.npc-conversation-title {
color: #f59e0b;
font-weight: 600;
font-size: 0.95rem;
}
.npc-conversation-history {
padding: 1rem;
max-height: 400px;
overflow-y: auto;
}
.dialogue-exchange {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #3a3a4a;
}
.dialogue-exchange:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.dialogue-exchange-past {
opacity: 0.7;
}
.dialogue-exchange-current {
background: #2a2a3a;
margin: 0 -1rem;
padding: 1rem;
border-radius: 0;
opacity: 1;
}
.dialogue-player {
margin-bottom: 0.5rem;
}
.dialogue-npc {
padding-left: 0.5rem;
border-left: 2px solid #f59e0b;
}
.dialogue-speaker {
font-weight: 600;
margin-right: 0.5rem;
}
.dialogue-player .dialogue-speaker {
color: #60a5fa;
}
.dialogue-npc .dialogue-speaker {
color: #f59e0b;
}
.dialogue-text {
color: #e5e7eb;
line-height: 1.5;
white-space: pre-wrap;
}
.dialogue-exchange-past .dialogue-text {
color: #9ca3af;
}
</style>

View File

@@ -0,0 +1,32 @@
{# Session state partial #}
<div class="state-item">
<div class="state-label">Session ID</div>
<div class="state-value">{{ session_id[:12] }}...</div>
</div>
<div class="state-item">
<div class="state-label">Turn</div>
<div class="state-value">{{ session.turn_number }}</div>
</div>
<div class="state-item">
<div class="state-label">Location</div>
<div class="state-value">{{ session.game_state.current_location }}</div>
</div>
<div class="state-item">
<div class="state-label">Type</div>
<div class="state-value">{{ session.game_state.location_type }}</div>
</div>
<div class="state-item">
<div class="state-label">Active Quests</div>
<div class="state-value">{{ session.game_state.active_quests|length }}</div>
</div>
{% if session.game_state.active_quests %}
<div class="state-item" style="margin-top: 0.5rem;">
<div class="state-label">Quest IDs</div>
<div class="state-value" style="font-size: 0.75rem;">
{% for quest_id in session.game_state.active_quests %}
{{ quest_id[:10] }}...{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,31 @@
{# Travel Modal Partial - displays available travel destinations #}
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Travel to...</h3>
{% if locations %}
{% for location in locations %}
<button class="location-btn"
hx-post="{{ url_for('dev.do_travel', session_id=session_id) }}"
hx-vals='{"location_id": "{{ location.location_id }}"}'
hx-target="#dm-response"
hx-swap="innerHTML">
<div class="location-name">{{ location.name }}</div>
<div class="location-type">{{ location.location_type | default('Unknown') }}</div>
{% if location.description %}
<div class="location-desc" style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">
{{ location.description[:80] }}{% if location.description|length > 80 %}...{% endif %}
</div>
{% endif %}
</button>
{% endfor %}
{% else %}
<p style="color: #9ca3af; text-align: center;">No locations available to travel to.</p>
<p style="color: #6b7280; font-size: 0.85rem; text-align: center;">
Discover new locations by exploring and talking to NPCs.
</p>
{% endif %}
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
</div>
</div>

View File

@@ -0,0 +1,199 @@
{% extends "base.html" %}
{% block title %}Story Tester - Dev Tools{% endblock %}
{% block extra_head %}
<style>
.dev-banner {
background: #dc2626;
color: white;
padding: 0.5rem 1rem;
text-align: center;
font-weight: bold;
position: sticky;
top: 0;
z-index: 100;
}
.story-container {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.story-section {
background: rgba(30, 30, 40, 0.9);
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.story-section h2 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
border-bottom: 1px solid #4a4a5a;
padding-bottom: 0.5rem;
}
.character-select {
width: 100%;
padding: 0.75rem;
background: #2a2a3a;
border: 1px solid #4a4a5a;
border-radius: 6px;
color: white;
font-size: 1rem;
margin-bottom: 1rem;
}
.btn-create {
background: #10b981;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-create:hover {
background: #059669;
}
.btn-create:disabled {
background: #4a4a5a;
cursor: not-allowed;
}
.session-list {
list-style: none;
padding: 0;
margin: 0;
}
.session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #2a2a3a;
border-radius: 6px;
margin-bottom: 0.5rem;
}
.session-item a {
color: #60a5fa;
text-decoration: none;
}
.session-item a:hover {
text-decoration: underline;
}
.session-meta {
color: #9ca3af;
font-size: 0.85rem;
}
.error {
background: #7f1d1d;
color: #fecaca;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.no-characters {
color: #9ca3af;
text-align: center;
padding: 2rem;
}
.no-characters a {
color: #60a5fa;
}
#create-result {
margin-top: 1rem;
}
.success {
background: #065f46;
color: #a7f3d0;
padding: 1rem;
border-radius: 6px;
}
</style>
{% endblock %}
{% block content %}
<div class="dev-banner">
DEV MODE - Story Gameplay Tester
</div>
<div class="story-container">
<h1>Story System Tester</h1>
<p style="color: #9ca3af;"><a href="{{ url_for('dev.index') }}" style="color: #60a5fa;">&larr; Back to Dev Tools</a></p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<div class="story-section">
<h2>Create New Session</h2>
{% if characters %}
<form hx-post="{{ url_for('dev.create_session') }}"
hx-target="#create-result"
hx-swap="innerHTML">
<select name="character_id" class="character-select" required>
<option value="">-- Select a Character --</option>
{% for char in characters %}
<option value="{{ char.character_id }}">
{{ char.name }} ({{ char.class_name }} Lvl {{ char.level }})
</option>
{% endfor %}
</select>
<button type="submit" class="btn-create">
Create Story Session
</button>
</form>
<div id="create-result"></div>
{% else %}
<div class="no-characters">
<p>No characters found. <a href="{{ url_for('character_views.create_origin') }}">Create a character</a> first.</p>
</div>
{% endif %}
</div>
<div class="story-section">
<h2>Existing Sessions</h2>
{% if sessions %}
<ul class="session-list">
{% for session in sessions %}
<li class="session-item">
<div>
<a href="{{ url_for('dev.story_session', session_id=session.session_id) }}">
Session {{ session.session_id[:8] }}...
</a>
<div class="session-meta">
Turn {{ session.turn_number }} | {{ session.game_state.current_location }}
</div>
</div>
<span class="session-meta">
{{ session.character_id[:8] }}...
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p style="color: #9ca3af; text-align: center;">No active sessions. Create one above.</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,669 @@
{% extends "base.html" %}
{% block title %}Story Session - Dev Tools{% endblock %}
{% block extra_head %}
<style>
.dev-banner {
background: #dc2626;
color: white;
padding: 0.5rem 1rem;
text-align: center;
font-weight: bold;
position: sticky;
top: 0;
z-index: 100;
}
.session-container {
max-width: 1200px;
margin: 1rem auto;
padding: 0 1rem;
display: grid;
grid-template-columns: 250px 1fr 300px;
gap: 1rem;
}
@media (max-width: 1024px) {
.session-container {
grid-template-columns: 1fr;
}
}
.panel {
background: rgba(30, 30, 40, 0.9);
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1rem;
}
.panel h3 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
font-size: 1rem;
border-bottom: 1px solid #4a4a5a;
padding-bottom: 0.5rem;
}
/* Left sidebar - State */
.state-panel {
font-size: 0.85rem;
}
.state-item {
margin-bottom: 0.75rem;
}
.state-label {
color: #9ca3af;
font-size: 0.75rem;
text-transform: uppercase;
}
.state-value {
color: white;
font-weight: 500;
}
/* Main area */
.main-panel {
min-height: 500px;
}
#dm-response {
background: #1a1a2a;
border-radius: 6px;
padding: 1.5rem;
min-height: 200px;
line-height: 1.6;
white-space: pre-wrap;
margin-bottom: 1rem;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.action-btn {
background: #3b82f6;
color: white;
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
text-align: left;
transition: background 0.2s;
}
.action-btn:hover {
background: #2563eb;
}
.action-btn:disabled {
background: #4a4a5a;
cursor: wait;
}
.action-btn.action-premium {
background: #8b5cf6;
}
.action-btn.action-premium:hover {
background: #7c3aed;
}
.action-btn.action-elite {
background: #f59e0b;
}
.action-btn.action-elite:hover {
background: #d97706;
}
/* Right sidebar - History */
.history-panel {
max-height: 600px;
overflow-y: auto;
}
.history-entry {
padding: 0.75rem;
background: #2a2a3a;
border-radius: 6px;
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.history-turn {
color: #f59e0b;
font-weight: bold;
margin-bottom: 0.25rem;
}
.history-action {
color: #60a5fa;
margin-bottom: 0.5rem;
}
.history-response {
color: #d1d5db;
white-space: pre-wrap;
max-height: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Debug panel */
.debug-panel {
margin-top: 1rem;
padding: 1rem;
background: #1a1a2a;
border-radius: 6px;
font-family: monospace;
font-size: 0.75rem;
}
.debug-toggle {
color: #9ca3af;
cursor: pointer;
user-select: none;
}
.debug-content {
margin-top: 0.5rem;
max-height: 200px;
overflow: auto;
white-space: pre;
color: #a3e635;
}
.loading {
text-align: center;
padding: 1rem;
color: #60a5fa;
}
.error {
background: #7f1d1d;
color: #fecaca;
padding: 0.75rem;
border-radius: 6px;
}
.btn-refresh {
background: #6366f1;
color: white;
padding: 0.25rem 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
float: right;
}
/* NPC Sidebar */
.npc-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #4a4a5a;
}
.npc-section h4 {
color: #f59e0b;
font-size: 0.85rem;
margin: 0 0 0.5rem 0;
}
.npc-card {
cursor: pointer;
padding: 0.5rem;
margin: 0.25rem 0;
background: #2a2a3a;
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.2s;
}
.npc-card:hover {
background: #3a3a4a;
border-color: #5a5a6a;
}
.npc-name {
font-weight: 500;
color: #e5e7eb;
font-size: 0.9rem;
}
.npc-role {
font-size: 0.75rem;
color: #9ca3af;
}
.npc-empty {
color: #6b7280;
font-size: 0.8rem;
font-style: italic;
}
/* Travel Section */
.travel-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #4a4a5a;
}
.btn-travel {
width: 100%;
background: #059669;
color: white;
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.btn-travel:hover {
background: #047857;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #1a1a2a;
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1.5rem;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-content h3 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
}
.location-btn {
width: 100%;
padding: 0.75rem;
margin: 0.5rem 0;
background: #2a2a3a;
border: 1px solid #4a4a5a;
border-radius: 6px;
color: white;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.location-btn:hover {
background: #3a3a4a;
border-color: #5a5a6a;
}
.location-name {
font-weight: 500;
color: #e5e7eb;
}
.location-type {
font-size: 0.8rem;
color: #9ca3af;
}
.modal-close {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #6b7280;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.modal-close:hover {
background: #4b5563;
}
/* NPC Dialogue Result */
.npc-dialogue {
background: #2a2a3a;
border-left: 3px solid #f59e0b;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0 6px 6px 0;
}
.npc-dialogue-header {
color: #f59e0b;
font-weight: 500;
margin-bottom: 0.5rem;
}
/* NPC Chat Form Styles */
.npc-card-wrapper {
margin: 0.25rem 0;
background: #2a2a3a;
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.2s;
}
.npc-card-wrapper:hover {
background: #3a3a4a;
border-color: #5a5a6a;
}
.npc-card-header {
cursor: pointer;
padding: 0.5rem;
}
.npc-chat-form {
padding: 0.5rem;
padding-top: 0;
border-top: 1px solid #4a4a5a;
}
.npc-chat-input {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: #1a1a2a;
border: 1px solid #4a4a5a;
border-radius: 4px;
color: #e5e7eb;
font-size: 0.85rem;
}
.npc-chat-input:focus {
outline: none;
border-color: #f59e0b;
}
.npc-chat-input::placeholder {
color: #6b7280;
}
.npc-chat-buttons {
display: flex;
gap: 0.5rem;
}
.btn-npc-send {
flex: 1;
padding: 0.4rem 0.75rem;
background: #f59e0b;
border: none;
border-radius: 4px;
color: #1a1a2a;
font-weight: 500;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-npc-send:hover {
background: #d97706;
}
.btn-npc-send:disabled {
background: #4a4a5a;
color: #9ca3af;
cursor: wait;
}
.btn-npc-greet {
padding: 0.4rem 0.75rem;
background: #3b3b4b;
border: 1px solid #5a5a6a;
border-radius: 4px;
color: #e5e7eb;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-npc-greet:hover {
background: #4b4b5b;
border-color: #6a6a7a;
}
</style>
{% endblock %}
{% block content %}
<div class="dev-banner">
DEV MODE - Session {{ session_id[:8] }}...
</div>
<div class="session-container">
<!-- Left sidebar: State -->
<div class="panel state-panel">
<h3>
Session State
<button class="btn-refresh"
hx-get="{{ url_for('dev.get_state', session_id=session_id) }}"
hx-target="#state-content"
hx-swap="innerHTML">
Refresh
</button>
</h3>
<div id="state-content">
<div class="state-item">
<div class="state-label">Session ID</div>
<div class="state-value">{{ session_id[:12] }}...</div>
</div>
<div class="state-item">
<div class="state-label">Turn</div>
<div class="state-value">{{ session.turn_number }}</div>
</div>
<div class="state-item">
<div class="state-label">Location</div>
<div class="state-value">{{ session.game_state.current_location }}</div>
</div>
<div class="state-item">
<div class="state-label">Type</div>
<div class="state-value">{{ session.game_state.location_type }}</div>
</div>
<div class="state-item">
<div class="state-label">Active Quests</div>
<div class="state-value">{{ session.game_state.active_quests|length }}</div>
</div>
</div>
<!-- NPC Section -->
<div class="npc-section">
<h4>NPCs Here</h4>
<div id="npc-list">
{% if npcs_present %}
{% for npc in npcs_present %}
<div class="npc-card-wrapper">
<div class="npc-card-header"
onclick="toggleNpcChat('{{ npc.npc_id }}')">
<div class="npc-name">{{ npc.name }}</div>
<div class="npc-role">{{ npc.role }}</div>
</div>
<div class="npc-chat-form" id="npc-chat-{{ npc.npc_id }}" style="display: none;">
<form hx-post="{{ url_for('dev.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
hx-target="#dm-response"
hx-swap="innerHTML">
<input type="text"
name="player_response"
placeholder="Say something..."
class="npc-chat-input"
maxlength="500"
autocomplete="off">
<div class="npc-chat-buttons">
<button type="submit" class="btn-npc-send" hx-disabled-elt="this">Send</button>
<button type="button"
class="btn-npc-greet"
hx-post="{{ url_for('dev.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
hx-vals='{"topic": "greeting"}'
hx-target="#dm-response"
hx-swap="innerHTML"
hx-disabled-elt="this">Greet</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p class="npc-empty">No one here to talk to.</p>
{% endif %}
</div>
</div>
<!-- Travel Section -->
<div class="travel-section">
<button class="btn-travel"
hx-get="{{ url_for('dev.travel_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
Travel to...
</button>
</div>
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
<a href="{{ url_for('dev.story_hub') }}" style="color: #60a5fa; font-size: 0.85rem;">&larr; Back to Sessions</a>
</div>
</div>
<!-- Main area: DM Response & Actions -->
<div class="panel main-panel">
<h3>Story Gameplay</h3>
<!-- DM Response Area -->
<div id="dm-response">
{% if history %}
{{ history[-1].dm_response if history else 'Take an action to begin your adventure...' }}
{% else %}
Take an action to begin your adventure...
{% endif %}
</div>
<!-- Action Buttons -->
<div class="actions-grid" id="action-buttons">
{% if session.available_actions %}
{% for action in session.available_actions %}
<button class="action-btn {% if action.category == 'special' %}action-elite{% elif action.category in ['gather_info', 'travel'] and action.prompt_id in ['investigate_suspicious', 'follow_lead', 'make_camp'] %}action-premium{% endif %}"
hx-post="{{ url_for('dev.take_action', session_id=session_id) }}"
hx-vals='{"action_type": "button", "prompt_id": "{{ action.prompt_id }}"}'
hx-target="#dm-response"
hx-swap="innerHTML"
hx-disabled-elt="this"
title="{{ action.description }}">
{{ action.display_text }}
</button>
{% endfor %}
{% else %}
<p style="color: #9ca3af; text-align: center; grid-column: 1 / -1;">
No actions available for your tier/location. Try changing location or upgrading tier.
</p>
{% endif %}
</div>
<!-- Debug Panel -->
<div class="debug-panel">
<div class="debug-toggle" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
[+] Debug Info (click to toggle)
</div>
<div class="debug-content" style="display: none;">
Session ID: {{ session_id }}
Character ID: {{ session.character_id }}
Turn: {{ session.turn_number }}
Game State: {{ session.game_state | tojson }}
</div>
</div>
</div>
<!-- Right sidebar: History -->
<div class="panel history-panel">
<h3>
History
<button class="btn-refresh"
hx-get="{{ url_for('dev.get_history', session_id=session_id) }}"
hx-target="#history-content"
hx-swap="innerHTML">
Refresh
</button>
</h3>
<div id="history-content">
{% if history %}
{% for entry in history|reverse %}
<div class="history-entry">
<div class="history-turn">Turn {{ entry.turn }}</div>
<div class="history-action">{{ entry.action }}</div>
<div class="history-response">{{ entry.dm_response[:150] }}{% if entry.dm_response|length > 150 %}...{% endif %}</div>
</div>
{% endfor %}
{% else %}
<p style="color: #9ca3af; text-align: center; font-size: 0.85rem;">No history yet.</p>
{% endif %}
</div>
</div>
</div>
<!-- Modal Container for Travel/NPC dialogs -->
<div id="modal-container"></div>
<script>
// Toggle NPC chat form visibility
function toggleNpcChat(npcId) {
const chatForm = document.getElementById('npc-chat-' + npcId);
if (!chatForm) return;
// Close all other NPC chat forms
document.querySelectorAll('.npc-chat-form').forEach(form => {
if (form.id !== 'npc-chat-' + npcId) {
form.style.display = 'none';
}
});
// Toggle the clicked NPC's chat form
if (chatForm.style.display === 'none') {
chatForm.style.display = 'block';
// Focus the input field
const input = chatForm.querySelector('.npc-chat-input');
if (input) {
input.focus();
}
} else {
chatForm.style.display = 'none';
}
}
// Clear input after successful form submission
document.body.addEventListener('htmx:afterRequest', function(event) {
// Check if this was an NPC chat form submission
const form = event.detail.elt.closest('.npc-chat-form form');
if (form && event.detail.successful) {
const input = form.querySelector('.npc-chat-input');
if (input) {
input.value = '';
}
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Lost in the Void - Code of Conquest{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-content">
<!-- Error Code -->
<div class="error-code">404</div>
<!-- Error Title -->
<h1 class="error-title">Lost in the Void</h1>
<!-- Flavor Text -->
<div class="error-description">
<p class="lead">The path you seek has vanished into the mists...</p>
<p>This page has either been consumed by ancient magic, moved to another realm, or never existed in the first place.</p>
</div>
<!-- Decorative Divider -->
<div class="error-divider">⚔️</div>
<!-- Navigation Options -->
<div class="error-actions">
<a href="/" class="btn btn-primary">
Return to the Realm
</a>
<a href="{{ url_for('character_views.list_characters') }}" class="btn btn-secondary">
View Your Characters
</a>
</div>
<!-- Additional Help -->
<div class="error-help">
<p class="help-text">
If you believe this path should exist, consult the ancient scrolls (check your URL) or
<a href="/" class="text-link">return home</a> to begin your journey anew.
</p>
</div>
</div>
</div>
<style>
.error-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 200px);
padding: 2rem;
}
.error-content {
max-width: 600px;
text-align: center;
background: var(--bg-secondary);
padding: 3rem 2rem;
border-radius: 8px;
border: 2px solid var(--border-ornate);
box-shadow: var(--shadow-lg);
}
.error-code {
font-family: var(--font-heading);
font-size: 8rem;
font-weight: 700;
color: var(--accent-gold);
text-shadow: 0 0 30px rgba(243, 156, 18, 0.5);
line-height: 1;
margin-bottom: 1rem;
}
.error-title {
font-family: var(--font-heading);
font-size: var(--text-4xl);
color: var(--text-primary);
margin-bottom: 1.5rem;
text-transform: uppercase;
letter-spacing: 2px;
}
.error-description {
margin-bottom: 2rem;
}
.error-description .lead {
font-size: var(--text-xl);
color: var(--accent-gold);
font-style: italic;
margin-bottom: 1rem;
}
.error-description p {
color: var(--text-secondary);
line-height: 1.8;
}
.error-divider {
font-size: var(--text-2xl);
color: var(--border-ornate);
margin: 2rem 0;
}
.error-actions {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.error-actions .btn {
width: 100%;
}
.error-help {
padding-top: 2rem;
border-top: 1px solid var(--border-primary);
}
.help-text {
font-size: var(--text-sm);
color: var(--text-muted);
line-height: 1.6;
}
.text-link {
color: var(--accent-gold);
text-decoration: none;
transition: color 0.2s;
}
.text-link:hover {
color: var(--accent-gold-hover);
text-decoration: underline;
}
@media (min-width: 640px) {
.error-actions {
flex-direction: row;
justify-content: center;
}
.error-actions .btn {
width: auto;
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}Arcane Disruption - Code of Conquest{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-content">
<!-- Error Code -->
<div class="error-code">500</div>
<!-- Error Title -->
<h1 class="error-title">Arcane Disruption</h1>
<!-- Flavor Text -->
<div class="error-description">
<p class="lead">The magical wards have been breached!</p>
<p>A powerful enchantment has gone awry, causing a disturbance in the realm's core magic. Our court wizards are investigating the anomaly and working to restore balance.</p>
</div>
<!-- Decorative Divider -->
<div class="error-divider">🔮</div>
<!-- Navigation Options -->
<div class="error-actions">
<a href="/" class="btn btn-primary">
Return to Safety
</a>
<a href="javascript:window.location.reload()" class="btn btn-secondary">
Attempt Restoration
</a>
</div>
<!-- Additional Help -->
<div class="error-help">
<p class="help-text">
If this disruption persists, the kingdom's mages (our support team) may need to intervene.
Please try again in a few moments, or <a href="/" class="text-link">retreat to the main hall</a>.
</p>
</div>
<!-- Technical Details (for development) -->
{% if config.get('app', {}).get('debug', False) %}
<div class="error-debug">
<details>
<summary class="debug-summary">Arcane Runes (Debug Info)</summary>
<div class="debug-content">
<p><strong>Spell Component:</strong> Internal Server Error</p>
<p><strong>Magical Signature:</strong> {{ request.url }}</p>
<p><strong>Timestamp:</strong> {{ moment().format('YYYY-MM-DD HH:mm:ss') if moment else 'Unknown' }}</p>
</div>
</details>
</div>
{% endif %}
</div>
</div>
<style>
.error-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 200px);
padding: 2rem;
}
.error-content {
max-width: 600px;
text-align: center;
background: var(--bg-secondary);
padding: 3rem 2rem;
border-radius: 8px;
border: 2px solid var(--border-ornate);
box-shadow: var(--shadow-lg);
}
.error-code {
font-family: var(--font-heading);
font-size: 8rem;
font-weight: 700;
color: var(--accent-red-light);
text-shadow: 0 0 30px rgba(231, 76, 60, 0.5);
line-height: 1;
margin-bottom: 1rem;
}
.error-title {
font-family: var(--font-heading);
font-size: var(--text-4xl);
color: var(--text-primary);
margin-bottom: 1.5rem;
text-transform: uppercase;
letter-spacing: 2px;
}
.error-description {
margin-bottom: 2rem;
}
.error-description .lead {
font-size: var(--text-xl);
color: var(--accent-red-light);
font-style: italic;
margin-bottom: 1rem;
}
.error-description p {
color: var(--text-secondary);
line-height: 1.8;
}
.error-divider {
font-size: var(--text-2xl);
color: var(--border-ornate);
margin: 2rem 0;
}
.error-actions {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.error-actions .btn {
width: 100%;
}
.error-help {
padding-top: 2rem;
border-top: 1px solid var(--border-primary);
}
.help-text {
font-size: var(--text-sm);
color: var(--text-muted);
line-height: 1.6;
}
.text-link {
color: var(--accent-gold);
text-decoration: none;
transition: color 0.2s;
}
.text-link:hover {
color: var(--accent-gold-hover);
text-decoration: underline;
}
.error-debug {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border-primary);
}
.debug-summary {
cursor: pointer;
color: var(--text-muted);
font-size: var(--text-sm);
font-family: var(--font-mono);
padding: 0.5rem;
background: var(--bg-tertiary);
border-radius: 4px;
user-select: none;
}
.debug-summary:hover {
color: var(--text-secondary);
}
.debug-content {
margin-top: 1rem;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: 4px;
text-align: left;
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.debug-content p {
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.debug-content strong {
color: var(--accent-gold);
}
@media (min-width: 640px) {
.error-actions {
flex-direction: row;
justify-content: center;
}
.error-actions .btn {
width: auto;
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,186 @@
{#
Character Panel - Left sidebar
Displays character stats, resource bars, and action buttons
#}
<div class="character-panel">
{# Character Header #}
<div class="character-header">
<div class="character-name">{{ character.name }}</div>
<div class="character-info">
<span class="character-class">{{ character.class_name }}</span>
<span class="character-level">Level {{ character.level }}</span>
</div>
</div>
{# Resource Bars #}
<div class="resource-bars">
{# HP Bar #}
<div class="resource-bar resource-bar--hp">
<div class="resource-bar-label">
<span class="resource-bar-name">HP</span>
<span class="resource-bar-value">{{ character.current_hp }} / {{ character.max_hp }}</span>
</div>
<div class="resource-bar-track">
{% set hp_percent = (character.current_hp / character.max_hp * 100)|int %}
<div class="resource-bar-fill" style="width: {{ hp_percent }}%"></div>
</div>
</div>
{# MP Bar #}
<div class="resource-bar resource-bar--mp">
<div class="resource-bar-label">
<span class="resource-bar-name">MP</span>
<span class="resource-bar-value">{{ character.current_mp }} / {{ character.max_mp }}</span>
</div>
<div class="resource-bar-track">
{% set mp_percent = (character.current_mp / character.max_mp * 100)|int %}
<div class="resource-bar-fill" style="width: {{ mp_percent }}%"></div>
</div>
</div>
</div>
{# Stats Accordion (Collapsed by default) #}
<div class="panel-accordion collapsed" data-panel-accordion="stats">
<button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
<span>Stats</span>
<span class="panel-accordion-icon"></span>
</button>
<div class="panel-accordion-content">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-abbr">STR</div>
<div class="stat-value">{{ character.stats.strength }}</div>
</div>
<div class="stat-item">
<div class="stat-abbr">DEX</div>
<div class="stat-value">{{ character.stats.dexterity }}</div>
</div>
<div class="stat-item">
<div class="stat-abbr">CON</div>
<div class="stat-value">{{ character.stats.constitution }}</div>
</div>
<div class="stat-item">
<div class="stat-abbr">INT</div>
<div class="stat-value">{{ character.stats.intelligence }}</div>
</div>
<div class="stat-item">
<div class="stat-abbr">WIS</div>
<div class="stat-value">{{ character.stats.wisdom }}</div>
</div>
<div class="stat-item">
<div class="stat-abbr">CHA</div>
<div class="stat-value">{{ character.stats.charisma }}</div>
</div>
</div>
</div>
</div>
{# Quick Actions (Equipment, NPC, Travel) #}
<div class="quick-actions">
{# Equipment & Gear - Opens modal #}
<button class="action-btn action-btn--special"
hx-get="{{ url_for('game.equipment_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
⚔️ Equipment & Gear
</button>
{# Talk to NPC - Opens NPC accordion #}
<button class="action-btn action-btn--special"
hx-get="{{ url_for('game.npcs_accordion', session_id=session_id) }}"
hx-target="#accordion-npcs"
hx-swap="innerHTML"
onclick="document.querySelector('[data-accordion=npcs]').classList.remove('collapsed')">
💬 Talk to NPC...
</button>
{# Travel - Opens modal #}
<button class="action-btn action-btn--special"
hx-get="{{ url_for('game.travel_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
🗺️ Travel to...
</button>
</div>
{# Actions Section #}
<div class="actions-section">
<div class="actions-title">Actions</div>
{# Free Tier Actions #}
<div class="actions-tier">
<div class="actions-tier-label">Free Actions</div>
<div class="actions-list">
{% for action in actions.free %}
{% set available = action.context == ['any'] or location.location_type in action.context %}
<button class="action-btn action-btn--free"
hx-post="{{ url_for('game.take_action', session_id=session_id) }}"
hx-vals='{"action_type": "button", "prompt_id": "{{ action.prompt_id }}"}'
hx-target="#narrative-content"
hx-swap="innerHTML"
hx-disabled-elt="this"
{% if not available %}disabled title="Not available in this location"{% endif %}>
<span class="action-btn-text">{{ action.display_text }}</span>
{% if action.cooldown %}
<span class="action-btn-cooldown" title="{{ action.cooldown }} turn cooldown"></span>
{% endif %}
</button>
{% endfor %}
</div>
</div>
{# Premium Tier Actions #}
<div class="actions-tier">
<div class="actions-tier-label">Premium Actions</div>
<div class="actions-list">
{% for action in actions.premium %}
{% set available = action.context == ['any'] or location.location_type in action.context %}
{% set locked = user_tier not in ['premium', 'elite'] %}
<button class="action-btn {% if locked %}action-btn--locked{% else %}action-btn--premium{% endif %}"
{% if not locked %}
hx-post="{{ url_for('game.take_action', session_id=session_id) }}"
hx-vals='{"action_type": "button", "prompt_id": "{{ action.prompt_id }}"}'
hx-target="#narrative-content"
hx-swap="innerHTML"
hx-disabled-elt="this"
{% endif %}
{% if locked %}disabled title="Requires Premium tier"{% elif not available %}disabled title="Not available in this location"{% endif %}>
<span class="action-btn-text">{{ action.display_text }}</span>
{% if locked %}
<span class="action-btn-lock">🔒</span>
{% elif action.cooldown %}
<span class="action-btn-cooldown" title="{{ action.cooldown }} turn cooldown"></span>
{% endif %}
</button>
{% endfor %}
</div>
</div>
{# Elite Tier Actions #}
<div class="actions-tier">
<div class="actions-tier-label">Elite Actions</div>
<div class="actions-list">
{% for action in actions.elite %}
{% set available = action.context == ['any'] or location.location_type in action.context %}
{% set locked = user_tier != 'elite' %}
<button class="action-btn {% if locked %}action-btn--locked{% else %}action-btn--elite{% endif %}"
{% if not locked %}
hx-post="{{ url_for('game.take_action', session_id=session_id) }}"
hx-vals='{"action_type": "button", "prompt_id": "{{ action.prompt_id }}"}'
hx-target="#narrative-content"
hx-swap="innerHTML"
hx-disabled-elt="this"
{% endif %}
{% if locked %}disabled title="Requires Elite tier"{% elif not available %}disabled title="Not available in this location"{% endif %}>
<span class="action-btn-text">{{ action.display_text }}</span>
{% if locked %}
<span class="action-btn-lock">🔒</span>
{% elif action.cooldown %}
<span class="action-btn-cooldown" title="{{ action.cooldown }} turn cooldown"></span>
{% endif %}
</button>
{% endfor %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
{#
DM Response Partial
Shows the completed DM response and triggers sidebar refreshes
#}
<div class="dm-response">
{{ dm_response }}
</div>
{# Hidden triggers to refresh sidebars after action completes #}
<div hx-get="{{ url_for('game.history_accordion', session_id=session_id) }}"
hx-trigger="load"
hx-target="#accordion-history"
hx-swap="innerHTML"
style="display: none;"></div>
<div hx-get="{{ url_for('game.npcs_accordion', session_id=session_id) }}"
hx-trigger="load"
hx-target="#accordion-npcs"
hx-swap="innerHTML"
style="display: none;"></div>
<div hx-get="{{ url_for('game.character_panel', session_id=session_id) }}"
hx-trigger="load"
hx-target="#character-panel"
hx-swap="innerHTML"
style="display: none;"></div>

View File

@@ -0,0 +1,108 @@
{#
Equipment Modal
Displays character's equipped gear and inventory summary
#}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--md">
{# Modal Header #}
<div class="modal-header">
<h3 class="modal-title">Equipment & Gear</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
{# Modal Body #}
<div class="modal-body">
{# Equipment Grid #}
<div class="equipment-grid">
{% set slots = [
('weapon', 'Weapon'),
('armor', 'Armor'),
('helmet', 'Helmet'),
('boots', 'Boots'),
('accessory', 'Accessory')
] %}
{% for slot_id, slot_name in slots %}
{% set item = character.equipped.get(slot_id) %}
<div class="equipment-slot {% if item %}equipment-slot--equipped{% else %}equipment-slot--empty{% endif %}"
data-slot="{{ slot_id }}">
<div class="slot-header">
<span class="slot-label">{{ slot_name }}</span>
</div>
{% if item %}
{# Equipped Item #}
<div class="slot-item">
<div class="slot-icon">
{% if item.item_type == 'weapon' %}⚔️
{% elif item.item_type == 'armor' %}🛡️
{% elif item.item_type == 'helmet' %}⛑️
{% elif item.item_type == 'boots' %}👢
{% elif item.item_type == 'accessory' %}💍
{% else %}📦{% endif %}
</div>
<div class="slot-details">
<div class="slot-item-name">{{ item.name }}</div>
<div class="slot-stats">
{% if item.damage %}
<span class="stat-damage">{{ item.damage }} DMG</span>
{% endif %}
{% if item.defense %}
<span class="stat-defense">{{ item.defense }} DEF</span>
{% endif %}
{% if item.stat_bonuses %}
{% for stat, bonus in item.stat_bonuses.items() %}
<span class="stat-bonus">+{{ bonus }} {{ stat[:3].upper() }}</span>
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% else %}
{# Empty Slot #}
<div class="slot-empty">
<div class="slot-icon slot-icon--empty">
{% if slot_id == 'weapon' %}⚔️
{% elif slot_id == 'armor' %}🛡️
{% elif slot_id == 'helmet' %}⛑️
{% elif slot_id == 'boots' %}👢
{% elif slot_id == 'accessory' %}💍
{% else %}📦{% endif %}
</div>
<div class="slot-empty-text">Empty</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{# Equipment Summary #}
<div class="equipment-summary">
<div class="summary-title">Total Bonuses</div>
<div class="summary-stats">
{% set total_bonuses = {} %}
{% for slot_id, item in character.equipped.items() %}
{% if item and item.stat_bonuses %}
{% for stat, bonus in item.stat_bonuses.items() %}
{% if total_bonuses.update({stat: total_bonuses.get(stat, 0) + bonus}) %}{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% if total_bonuses %}
{% for stat, bonus in total_bonuses.items() %}
<span class="summary-stat">+{{ bonus }} {{ stat[:3].upper() }}</span>
{% endfor %}
{% else %}
<span class="summary-none">No stat bonuses</span>
{% endif %}
</div>
</div>
</div>
{# Modal Footer #}
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
{#
Job Polling Partial
Shows loading state while waiting for AI response, auto-polls for completion
#}
{% if player_action %}
<div class="player-action-echo">
<span class="player-action-label">Your action:</span>
<span class="player-action-text">{{ player_action }}</span>
</div>
{% endif %}
<div class="loading-state"
hx-get="{{ url_for('game.poll_job', session_id=session_id, job_id=job_id) }}"
hx-trigger="load delay:1s"
hx-swap="innerHTML"
hx-target="#narrative-content">
<div class="loading-spinner-large"></div>
<p class="loading-text">
{% if status == 'queued' %}
Awaiting the Dungeon Master...
{% elif status == 'processing' %}
The Dungeon Master considers your action...
{% else %}
Processing...
{% endif %}
</p>
</div>

View File

@@ -0,0 +1,66 @@
{#
Narrative Panel - Middle section
Displays location header, ambient details, and DM response
#}
<div class="narrative-panel">
{# Location Header #}
<div class="location-header">
<div class="location-top">
<span class="location-type-badge location-type-badge--{{ location.location_type }}">
{{ location.location_type }}
</span>
<h2 class="location-name">{{ location.name }}</h2>
</div>
<div class="location-meta">
<span class="location-region">{{ location.region }}</span>
<span class="turn-counter">Turn {{ session.turn_number }}</span>
</div>
</div>
{# Ambient Details (Collapsible) #}
{% if location.ambient_description %}
<div class="ambient-section">
<button class="ambient-toggle" onclick="toggleAmbient()">
<span>Ambient Details</span>
<span class="ambient-icon"></span>
</button>
<div class="ambient-content">
{{ location.ambient_description }}
</div>
</div>
{% endif %}
{# DM Response Area #}
<div class="narrative-content" id="narrative-content">
<div class="dm-response">
{{ dm_response }}
</div>
</div>
{# Player Input Area #}
<div class="player-input-area">
<form class="player-input-form"
hx-post="{{ url_for('game.take_action', session_id=session_id) }}"
hx-target="#narrative-content"
hx-swap="innerHTML"
hx-disabled-elt="find button">
<label for="player-action" class="player-input-label">What will you do?</label>
<div class="player-input-row">
<input type="hidden" name="action_type" value="text">
<textarea id="player-action"
name="action_text"
class="player-input-textarea"
placeholder="Describe your action... (e.g., 'I draw my sword and approach the tavern keeper')"
rows="2"
required></textarea>
<button type="submit" class="player-input-submit">
<span class="submit-text">Act</span>
<span class="submit-icon"></span>
</button>
</div>
<div class="player-input-hint">
Press Enter to submit, Shift+Enter for new line
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,127 @@
{#
NPC Chat Modal (Expanded)
Shows NPC profile with portrait, relationship meter, and conversation interface
#}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--lg">
{# Modal Header #}
<div class="modal-header">
<h3 class="modal-title">{{ npc.name }}</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
{# Modal Body - Two Column Layout #}
<div class="modal-body npc-modal-body">
{# Left Column: NPC Profile #}
<div class="npc-profile">
{# NPC Portrait #}
<div class="npc-portrait">
{% if npc.image_url %}
<img src="{{ npc.image_url }}" alt="{{ npc.name }}">
{% else %}
<div class="npc-portrait-placeholder">
{{ npc.name[0] }}
</div>
{% endif %}
</div>
{# NPC Info #}
<div class="npc-profile-info">
<div class="npc-profile-role">{{ npc.role }}</div>
{% if npc.appearance %}
<div class="npc-profile-appearance">{{ npc.appearance }}</div>
{% endif %}
</div>
{# NPC Tags #}
{% if npc.tags %}
<div class="npc-profile-tags">
{% for tag in npc.tags %}
<span class="npc-profile-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
{# Relationship Meter #}
<div class="npc-relationship">
<div class="relationship-header">
<span class="relationship-label">Relationship</span>
<span class="relationship-value">{{ relationship_level|default(50) }}/100</span>
</div>
<div class="relationship-bar">
<div class="relationship-fill" style="width: {{ relationship_level|default(50) }}%"></div>
</div>
<div class="relationship-status">
{% set level = relationship_level|default(50) %}
{% if level >= 80 %}
<span class="status-friendly">Trusted Ally</span>
{% elif level >= 60 %}
<span class="status-friendly">Friendly</span>
{% elif level >= 40 %}
<span class="status-neutral">Neutral</span>
{% elif level >= 20 %}
<span class="status-unfriendly">Wary</span>
{% else %}
<span class="status-unfriendly">Hostile</span>
{% endif %}
</div>
</div>
{# Interaction Stats #}
<div class="npc-interaction-stats">
<div class="interaction-stat">
<span class="stat-label">Conversations</span>
<span class="stat-value">{{ interaction_count|default(0) }}</span>
</div>
</div>
</div>
{# Right Column: Conversation #}
<div class="npc-conversation">
{# Conversation History #}
<div class="chat-history" id="chat-history-{{ npc.npc_id }}">
{% if conversation_history %}
{% for msg in conversation_history %}
<div class="chat-message chat-message--{{ msg.speaker }}">
<strong>{{ msg.speaker_name }}:</strong> {{ msg.text }}
</div>
{% endfor %}
{% else %}
<div class="chat-empty-state">
Start a conversation with {{ npc.name }}
</div>
{% endif %}
</div>
{# Chat Input #}
<form class="chat-input-form"
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
hx-target="#chat-history-{{ npc.npc_id }}"
hx-swap="beforeend">
<input type="text"
name="player_response"
class="chat-input"
placeholder="Say something..."
autocomplete="off"
autofocus>
<button type="submit" class="chat-send-btn">Send</button>
<button type="button"
class="chat-greet-btn"
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
hx-vals='{"topic": "greeting"}'
hx-target="#chat-history-{{ npc.npc_id }}"
hx-swap="beforeend">
Greet
</button>
</form>
</div>
</div>
{# Modal Footer #}
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">
End Conversation
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,59 @@
{#
NPC Dialogue Response partial - displays conversation with current exchange.
Used when job polling returns NPC dialogue results.
Expected context:
- npc_name: Name of the NPC
- character_name: Name of the player character
- conversation_history: List of previous exchanges [{player_line, npc_response}, ...]
- player_line: What the player just said
- dialogue: NPC's current response
- session_id: For any follow-up actions
#}
<div class="npc-dialogue-response">
<div class="npc-dialogue-header">
<span class="npc-dialogue-title">{{ npc_name }} says:</span>
</div>
<div class="npc-dialogue-content">
{# Show conversation history if present #}
{% if conversation_history %}
<div class="conversation-history">
{% for exchange in conversation_history[-3:] %}
<div class="history-exchange">
<div class="history-player">
<span class="speaker player">{{ character_name }}:</span>
<span class="text">{{ exchange.player_line }}</span>
</div>
<div class="history-npc">
<span class="speaker npc">{{ npc_name }}:</span>
<span class="text">{{ exchange.npc_response }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Show current exchange #}
<div class="current-exchange">
{% if player_line %}
<div class="player-message">
<span class="speaker player">{{ character_name }}:</span>
<span class="text">{{ player_line }}</span>
</div>
{% endif %}
<div class="npc-message">
<span class="speaker npc">{{ npc_name }}:</span>
<span class="text">{{ dialogue }}</span>
</div>
</div>
</div>
</div>
{# Trigger sidebar refreshes after NPC dialogue #}
<div hx-get="{{ url_for('game.npcs_accordion', session_id=session_id) }}"
hx-trigger="load"
hx-target="#accordion-npcs"
hx-swap="innerHTML"
style="display: none;"></div>

View File

@@ -0,0 +1,30 @@
{#
History Accordion Content
Shows previous turns with actions and DM responses
#}
{% if history %}
<div class="history-list">
{% for entry in history %}
<div class="history-item">
<div class="history-item-header">
<span class="history-turn">Turn {{ entry.turn }}</span>
</div>
<div class="history-action">{{ entry.action }}</div>
<div class="history-response">{{ entry.dm_response|truncate(150) }}</div>
</div>
{% endfor %}
</div>
<div class="history-load-more">
<button class="btn-load-more"
hx-get="{{ url_for('game.history_accordion', session_id=session_id) }}?offset={{ history|length }}"
hx-target="#accordion-history"
hx-swap="beforeend">
Load More
</button>
</div>
{% else %}
<div class="quest-empty">
No history yet. Take your first action!
</div>
{% endif %}

View File

@@ -0,0 +1,51 @@
{#
Map Accordion Content
Shows discovered locations grouped by region
#}
{% if discovered_locations %}
{# Group locations by region #}
{% set regions = {} %}
{% for loc in discovered_locations %}
{% set region = loc.region %}
{% if region not in regions %}
{% set _ = regions.update({region: []}) %}
{% endif %}
{% set _ = regions[region].append(loc) %}
{% endfor %}
{% for region_name, locations in regions.items() %}
<div class="map-region">
<div class="map-region-name">{{ region_name }}</div>
<div class="map-locations">
{% for loc in locations %}
<div class="map-location {% if loc.is_current %}current{% endif %}"
{% if not loc.is_current %}
hx-post="{{ url_for('game.do_travel', session_id=session_id) }}"
hx-vals='{"location_id": "{{ loc.location_id }}"}'
hx-target="#narrative-content"
hx-swap="innerHTML"
hx-confirm="Travel to {{ loc.name }}?"
{% endif %}>
<span class="map-location-icon">
{% if loc.location_type == 'town' %}🏘️
{% elif loc.location_type == 'tavern' %}🍺
{% elif loc.location_type == 'wilderness' %}🌲
{% elif loc.location_type == 'dungeon' %}⚔️
{% elif loc.location_type == 'ruins' %}🏚️
{% else %}📍
{% endif %}
</span>
<span class="map-location-name">{{ loc.name }}</span>
<span class="map-location-type">
{% if loc.is_current %}(here){% else %}{{ loc.location_type }}{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="quest-empty">
No locations discovered yet.
</div>
{% endif %}

View File

@@ -0,0 +1,29 @@
{#
NPCs Accordion Content
Shows NPCs at current location with click to chat
#}
{% if npcs %}
<div class="npc-list">
{% for npc in npcs %}
<div class="npc-item"
hx-get="{{ url_for('game.npc_chat_modal', session_id=session_id, npc_id=npc.npc_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
<div class="npc-name">{{ npc.name }}</div>
<div class="npc-role">{{ npc.role }}</div>
<div class="npc-appearance">{{ npc.appearance }}</div>
{% if npc.tags %}
<div class="npc-tags">
{% for tag in npc.tags %}
<span class="npc-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="quest-empty">
No NPCs at this location.
</div>
{% endif %}

View File

@@ -0,0 +1,36 @@
{#
Quests Accordion Content
Shows active quests with objectives and progress
#}
{% if quests %}
<div class="quest-list">
{% for quest in quests %}
<div class="quest-item">
<div class="quest-header">
<span class="quest-name">{{ quest.name }}</span>
<span class="quest-difficulty quest-difficulty--{{ quest.difficulty }}">
{{ quest.difficulty }}
</span>
</div>
<div class="quest-giver">From: {{ quest.quest_giver }}</div>
<div class="quest-objectives">
{% for objective in quest.objectives %}
<div class="quest-objective">
<span class="quest-objective-check {% if objective.completed %}completed{% endif %}">
{% if objective.completed %}✓{% endif %}
</span>
<span class="quest-objective-text">{{ objective.description }}</span>
{% if objective.required > 1 %}
<span class="quest-objective-progress">{{ objective.current }}/{{ objective.required }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="quest-empty">
No active quests. Talk to NPCs to find adventures!
</div>
{% endif %}

View File

@@ -0,0 +1,51 @@
{#
Travel Modal
Shows available destinations for travel
#}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">🗺️ Travel</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p style="color: var(--text-secondary); margin-bottom: 1rem; font-size: var(--text-sm);">
Choose your destination:
</p>
{% if destinations %}
<div class="travel-destinations">
{% for loc in destinations %}
<button class="travel-destination"
hx-post="{{ url_for('game.do_travel', session_id=session_id) }}"
hx-vals='{"location_id": "{{ loc.location_id }}"}'
hx-target="#narrative-content"
hx-swap="innerHTML">
<div class="travel-destination-name">
{% if loc.location_type == 'town' %}🏘️
{% elif loc.location_type == 'tavern' %}🍺
{% elif loc.location_type == 'wilderness' %}🌲
{% elif loc.location_type == 'dungeon' %}⚔️
{% elif loc.location_type == 'ruins' %}🏚️
{% else %}📍
{% endif %}
{{ loc.name }}
</div>
<div class="travel-destination-meta">
{{ loc.location_type|capitalize }} • {{ loc.region }}
</div>
</button>
{% endfor %}
</div>
{% else %}
<div class="quest-empty">
No other locations discovered yet. Explore to find new places!
</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()" style="width: auto; padding: 0.5rem 1rem;">
Cancel
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}Playing - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
{% endblock %}
{% block content %}
<div class="play-container">
{# ===== LEFT SIDEBAR - Character Panel ===== #}
<aside class="play-panel play-sidebar play-sidebar--left" id="character-panel">
{% include "game/partials/character_panel.html" %}
</aside>
{# ===== MIDDLE - Narrative Panel ===== #}
<section class="play-panel play-main">
{% include "game/partials/narrative_panel.html" %}
</section>
{# ===== RIGHT SIDEBAR - Accordions ===== #}
<aside class="play-panel play-sidebar play-sidebar--right accordion-panel">
{# History Accordion #}
<div class="accordion" data-accordion="history">
<button class="accordion-header" onclick="toggleAccordion(this)">
<span>History <span class="accordion-count">({{ history|length }})</span></span>
<span class="accordion-icon"></span>
</button>
<div class="accordion-content" id="accordion-history">
{% include "game/partials/sidebar_history.html" %}
</div>
</div>
{# Quests Accordion #}
<div class="accordion collapsed" data-accordion="quests">
<button class="accordion-header" onclick="toggleAccordion(this)">
<span>Quests <span class="accordion-count">({{ quests|length }})</span></span>
<span class="accordion-icon"></span>
</button>
<div class="accordion-content" id="accordion-quests">
{% include "game/partials/sidebar_quests.html" %}
</div>
</div>
{# NPCs Accordion #}
<div class="accordion collapsed" data-accordion="npcs">
<button class="accordion-header" onclick="toggleAccordion(this)">
<span>NPCs Here <span class="accordion-count">({{ npcs|length }})</span></span>
<span class="accordion-icon"></span>
</button>
<div class="accordion-content" id="accordion-npcs">
{% include "game/partials/sidebar_npcs.html" %}
</div>
</div>
{# Map Accordion #}
<div class="accordion collapsed" data-accordion="map">
<button class="accordion-header" onclick="toggleAccordion(this)">
<span>Map <span class="accordion-count">({{ discovered_locations|length }})</span></span>
<span class="accordion-icon"></span>
</button>
<div class="accordion-content" id="accordion-map">
{% include "game/partials/sidebar_map.html" %}
</div>
</div>
</aside>
</div>
{# Modal Container #}
<div id="modal-container"></div>
{% endblock %}
{% block scripts %}
<script>
// Accordion Toggle (right sidebar)
function toggleAccordion(button) {
const accordion = button.closest('.accordion');
accordion.classList.toggle('collapsed');
}
// Panel Accordion Toggle (character panel)
function togglePanelAccordion(button) {
const accordion = button.closest('.panel-accordion');
accordion.classList.toggle('collapsed');
}
// Toggle Ambient Details
function toggleAmbient() {
const section = document.querySelector('.ambient-section');
section.classList.toggle('collapsed');
}
// Close Modal
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
}
});
// Close modal on overlay click
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal-overlay')) {
closeModal();
}
});
// Clear chat input after submission
document.body.addEventListener('htmx:afterSwap', function(e) {
// Clear chat input if it was a chat form submission
if (e.target.closest('.chat-history')) {
const form = document.querySelector('.chat-input-form');
if (form) {
const input = form.querySelector('.chat-input');
if (input) {
input.value = '';
input.focus();
}
}
}
// Clear player action input after submission
if (e.target.id === 'narrative-content') {
const textarea = document.querySelector('.player-input-textarea');
if (textarea) {
textarea.value = '';
textarea.focus();
}
}
});
// Player input: Enter to submit, Shift+Enter for new line
document.addEventListener('keydown', function(e) {
if (e.target.classList.contains('player-input-textarea')) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const form = e.target.closest('form');
if (form && e.target.value.trim()) {
htmx.trigger(form, 'submit');
}
}
}
});
</script>
{% endblock %}

24
public_web/wsgi.py Normal file
View File

@@ -0,0 +1,24 @@
"""
WSGI entry point for Code of Conquest Public Web Frontend.
Used by production WSGI servers like Gunicorn.
"""
from app import create_app
# Create application instance
app = create_app()
if __name__ == "__main__":
# For development only
# In production, use: gunicorn wsgi:app
# Get server config from loaded config
server_config = app.config.get('server', {})
app_config = app.config.get('app', {})
host = server_config.get('host', '0.0.0.0')
port = server_config.get('port', 8000)
debug = app_config.get('debug', True)
app.run(host=host, port=port, debug=debug)