first commit
This commit is contained in:
563
api/docs/ACTION_PROMPTS.md
Normal file
563
api/docs/ACTION_PROMPTS.md
Normal file
@@ -0,0 +1,563 @@
|
||||
# Action Prompts System
|
||||
|
||||
## Overview
|
||||
|
||||
Action prompts are predefined story actions that players can select during gameplay. They appear as buttons in the story UI, with availability filtered by subscription tier and location type.
|
||||
|
||||
**Key Features:**
|
||||
- YAML-driven configuration
|
||||
- Tier-based availability (Free, Basic, Premium, Elite)
|
||||
- Location-based context filtering
|
||||
- Custom AI prompt templates per action
|
||||
|
||||
**Files:**
|
||||
- **Model:** `app/models/action_prompt.py`
|
||||
- **Loader:** `app/services/action_prompt_loader.py`
|
||||
- **Data:** `app/data/action_prompts.yaml`
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ action_prompts.yaml │ ← YAML configuration
|
||||
├─────────────────────────┤
|
||||
│ ActionPromptLoader │ ← Singleton loader/cache
|
||||
├─────────────────────────┤
|
||||
│ ActionPrompt │ ← Data model
|
||||
├─────────────────────────┤
|
||||
│ Story UI / API │ ← Filtered action buttons
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ActionPrompt Model
|
||||
|
||||
**File:** `app/models/action_prompt.py`
|
||||
|
||||
### Fields
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ActionPrompt:
|
||||
prompt_id: str # Unique identifier
|
||||
category: ActionCategory # Action category
|
||||
display_text: str # Button label
|
||||
description: str # Tooltip text
|
||||
tier_required: UserTier # Minimum tier
|
||||
context_filter: List[LocationType] # Where available
|
||||
dm_prompt_template: str # AI prompt template
|
||||
icon: Optional[str] = None # Optional icon name
|
||||
cooldown_turns: int = 0 # Turns before reuse
|
||||
```
|
||||
|
||||
### ActionCategory Enum
|
||||
|
||||
```python
|
||||
class ActionCategory(str, Enum):
|
||||
ASK_QUESTION = "ask_question" # Gather info from NPCs
|
||||
TRAVEL = "travel" # Move to new location
|
||||
GATHER_INFO = "gather_info" # Search/investigate
|
||||
REST = "rest" # Rest and recover
|
||||
INTERACT = "interact" # Interact with objects
|
||||
EXPLORE = "explore" # Explore the area
|
||||
SPECIAL = "special" # Tier-specific special actions
|
||||
```
|
||||
|
||||
### LocationType Enum
|
||||
|
||||
```python
|
||||
class LocationType(str, Enum):
|
||||
TOWN = "town" # Populated settlements
|
||||
TAVERN = "tavern" # Taverns and inns
|
||||
WILDERNESS = "wilderness" # Outdoor areas
|
||||
DUNGEON = "dungeon" # Dungeons and caves
|
||||
SAFE_AREA = "safe_area" # Protected zones
|
||||
LIBRARY = "library" # Libraries and archives
|
||||
ANY = "any" # Available everywhere
|
||||
```
|
||||
|
||||
### Availability Methods
|
||||
|
||||
```python
|
||||
from app.models.action_prompt import ActionPrompt, LocationType
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
# Check if available
|
||||
if action.is_available(UserTier.FREE, LocationType.TOWN):
|
||||
# Show action to player
|
||||
pass
|
||||
|
||||
# Check if locked (tier too low)
|
||||
if action.is_locked(UserTier.FREE):
|
||||
reason = action.get_lock_reason(UserTier.FREE)
|
||||
# "Requires Premium tier or higher"
|
||||
```
|
||||
|
||||
### Serialization
|
||||
|
||||
```python
|
||||
# To dictionary
|
||||
data = action.to_dict()
|
||||
|
||||
# From dictionary
|
||||
action = ActionPrompt.from_dict(data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ActionPromptLoader Service
|
||||
|
||||
**File:** `app/services/action_prompt_loader.py`
|
||||
|
||||
Singleton service that loads and caches action prompts from YAML.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from app.services.action_prompt_loader import ActionPromptLoader
|
||||
from app.models.action_prompt import LocationType
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
loader = ActionPromptLoader()
|
||||
|
||||
# Load from YAML (or auto-loads from default path)
|
||||
loader.load_from_yaml("app/data/action_prompts.yaml")
|
||||
|
||||
# Get available actions for user at location
|
||||
actions = loader.get_available_actions(
|
||||
user_tier=UserTier.FREE,
|
||||
location_type=LocationType.TOWN
|
||||
)
|
||||
|
||||
for action in actions:
|
||||
print(f"{action.display_text} - {action.description}")
|
||||
```
|
||||
|
||||
### Query Methods
|
||||
|
||||
```python
|
||||
# Get specific action
|
||||
action = loader.get_action_by_id("ask_locals")
|
||||
|
||||
# Get all actions
|
||||
all_actions = loader.get_all_actions()
|
||||
|
||||
# Get actions by tier (ignoring location)
|
||||
tier_actions = loader.get_actions_by_tier(UserTier.PREMIUM)
|
||||
|
||||
# Get actions by category
|
||||
questions = loader.get_actions_by_category("ask_question")
|
||||
|
||||
# Get locked actions (for upgrade prompts)
|
||||
locked = loader.get_locked_actions(UserTier.FREE, LocationType.TOWN)
|
||||
```
|
||||
|
||||
### Utility Methods
|
||||
|
||||
```python
|
||||
# Check if loaded
|
||||
if loader.is_loaded():
|
||||
count = loader.get_prompt_count()
|
||||
|
||||
# Force reload
|
||||
loader.reload("app/data/action_prompts.yaml")
|
||||
|
||||
# Reset singleton (for testing)
|
||||
ActionPromptLoader.reset_instance()
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
from app.services.action_prompt_loader import (
|
||||
ActionPromptLoader,
|
||||
ActionPromptLoaderError,
|
||||
ActionPromptNotFoundError
|
||||
)
|
||||
|
||||
try:
|
||||
action = loader.get_action_by_id("invalid_id")
|
||||
except ActionPromptNotFoundError:
|
||||
# Action not found
|
||||
pass
|
||||
|
||||
try:
|
||||
loader.load_from_yaml("invalid_path.yaml")
|
||||
except ActionPromptLoaderError as e:
|
||||
# File not found, invalid YAML, etc.
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## YAML Configuration
|
||||
|
||||
**File:** `app/data/action_prompts.yaml`
|
||||
|
||||
### Structure
|
||||
|
||||
```yaml
|
||||
action_prompts:
|
||||
- prompt_id: unique_id
|
||||
category: ask_question
|
||||
display_text: Button Label
|
||||
description: Tooltip description
|
||||
tier_required: free
|
||||
context_filter: [town, tavern]
|
||||
icon: chat
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
Jinja2 template for AI prompt...
|
||||
```
|
||||
|
||||
### Available Actions
|
||||
|
||||
#### Free Tier (4 actions)
|
||||
|
||||
| ID | Display Text | Category | Locations |
|
||||
|----|-------------|----------|-----------|
|
||||
| `ask_locals` | Ask locals for information | ask_question | town, tavern |
|
||||
| `explore_area` | Explore the area | explore | wilderness, dungeon |
|
||||
| `search_supplies` | Search for supplies | gather_info | any |
|
||||
| `rest_recover` | Rest and recover | rest | town, tavern, safe_area |
|
||||
|
||||
#### Premium Tier (+3 actions)
|
||||
|
||||
| ID | Display Text | Category | Locations |
|
||||
|----|-------------|----------|-----------|
|
||||
| `investigate_suspicious` | Investigate suspicious activity | gather_info | any |
|
||||
| `follow_lead` | Follow a lead | travel | any |
|
||||
| `make_camp` | Make camp | rest | wilderness |
|
||||
|
||||
#### Elite Tier (+3 actions)
|
||||
|
||||
| ID | Display Text | Category | Locations |
|
||||
|----|-------------|----------|-----------|
|
||||
| `consult_texts` | Consult ancient texts | special | library, town |
|
||||
| `commune_nature` | Commune with nature | special | wilderness |
|
||||
| `seek_audience` | Seek audience with authorities | special | town |
|
||||
|
||||
### Total by Tier
|
||||
|
||||
- **Free:** 4 actions
|
||||
- **Premium:** 7 actions (Free + 3)
|
||||
- **Elite:** 10 actions (Premium + 3)
|
||||
|
||||
---
|
||||
|
||||
## DM Prompt Templates
|
||||
|
||||
Each action has a Jinja2 template for generating AI prompts.
|
||||
|
||||
### Available Variables
|
||||
|
||||
```jinja2
|
||||
{{ character.name }}
|
||||
{{ character.level }}
|
||||
{{ character.player_class }}
|
||||
{{ character.current_hp }}
|
||||
{{ character.max_hp }}
|
||||
{{ character.stats.strength }}
|
||||
{{ character.stats.dexterity }}
|
||||
{{ character.stats.constitution }}
|
||||
{{ character.stats.intelligence }}
|
||||
{{ character.stats.wisdom }}
|
||||
{{ character.stats.charisma }}
|
||||
{{ character.reputation }}
|
||||
|
||||
{{ game_state.current_location }}
|
||||
{{ game_state.location_type }}
|
||||
{{ game_state.active_quests }}
|
||||
```
|
||||
|
||||
### Template Example
|
||||
|
||||
```yaml
|
||||
dm_prompt_template: |
|
||||
The player explores the area around {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Perception modifier: {{ character.stats.wisdom | default(10) }}
|
||||
|
||||
Describe what the player discovers:
|
||||
- Environmental details and atmosphere
|
||||
- Points of interest (paths, structures, natural features)
|
||||
- Any items, tracks, or clues found
|
||||
- Potential dangers or opportunities
|
||||
|
||||
Based on their Wisdom score, they may notice hidden details.
|
||||
End with options for where to go or what to investigate next.
|
||||
```
|
||||
|
||||
### How Templates Flow to AI
|
||||
|
||||
The `dm_prompt_template` is passed through the system as follows:
|
||||
|
||||
1. **Sessions API** loads the action prompt and extracts `dm_prompt_template`
|
||||
2. **AI task** receives it in the context as `dm_prompt_template`
|
||||
3. **NarrativeGenerator** receives it as `action_instructions` parameter
|
||||
4. **story_action.j2** injects it under "Action-Specific Instructions"
|
||||
|
||||
```python
|
||||
# In ai_tasks.py
|
||||
response = generator.generate_story_response(
|
||||
character=context['character'],
|
||||
action=context['action'],
|
||||
game_state=context['game_state'],
|
||||
user_tier=user_tier,
|
||||
action_instructions=context.get('dm_prompt_template') # From action prompt
|
||||
)
|
||||
```
|
||||
|
||||
### Player Agency Rules
|
||||
|
||||
All action templates should follow these critical rules to maintain player agency:
|
||||
|
||||
```yaml
|
||||
# Example with player agency enforcement
|
||||
dm_prompt_template: |
|
||||
The player searches for supplies in {{ game_state.current_location }}.
|
||||
|
||||
IMPORTANT - This is a SEARCH action, not a purchase action:
|
||||
- In towns/markets: Describe vendors and wares with PRICES. Ask what to buy.
|
||||
- In wilderness: Describe what they FIND. Ask if they want to gather.
|
||||
|
||||
NEVER automatically:
|
||||
- Purchase items or spend gold
|
||||
- Add items to inventory without asking
|
||||
- Complete any transaction
|
||||
|
||||
End with: "What would you like to do?"
|
||||
```
|
||||
|
||||
The base `story_action.j2` template also enforces these rules globally:
|
||||
- Never make decisions for the player
|
||||
- Never complete transactions without consent
|
||||
- Present choices and let the player decide
|
||||
|
||||
---
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### API Endpoint
|
||||
|
||||
```python
|
||||
@bp.route('/sessions/<session_id>/available-actions', methods=['GET'])
|
||||
@require_auth
|
||||
def get_available_actions(session_id):
|
||||
user = get_current_user()
|
||||
session = get_session(session_id)
|
||||
|
||||
loader = ActionPromptLoader()
|
||||
|
||||
# Get available actions
|
||||
available = loader.get_available_actions(
|
||||
user_tier=user.tier,
|
||||
location_type=session.game_state.location_type
|
||||
)
|
||||
|
||||
# Get locked actions for upgrade prompts
|
||||
locked = loader.get_locked_actions(
|
||||
user_tier=user.tier,
|
||||
location_type=session.game_state.location_type
|
||||
)
|
||||
|
||||
return api_response(
|
||||
status=200,
|
||||
result={
|
||||
"available_actions": [a.to_dict() for a in available],
|
||||
"locked_actions": [
|
||||
{
|
||||
**a.to_dict(),
|
||||
"lock_reason": a.get_lock_reason(user.tier)
|
||||
}
|
||||
for a in locked
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Story UI Template
|
||||
|
||||
```jinja2
|
||||
{% for action in available_actions %}
|
||||
<button
|
||||
class="action-btn"
|
||||
hx-post="/api/v1/sessions/{{ session_id }}/action"
|
||||
hx-vals='{"action_type": "button", "prompt_id": "{{ action.prompt_id }}"}'
|
||||
hx-target="#dm-response"
|
||||
title="{{ action.description }}">
|
||||
{% if action.icon %}<i class="icon-{{ action.icon }}"></i>{% endif %}
|
||||
{{ action.display_text }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
{% for action in locked_actions %}
|
||||
<button
|
||||
class="action-btn locked"
|
||||
disabled
|
||||
title="{{ action.lock_reason }}">
|
||||
{{ action.display_text }}
|
||||
<span class="lock-icon">🔒</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Processing Action in AI Task
|
||||
|
||||
```python
|
||||
from app.services.action_prompt_loader import ActionPromptLoader
|
||||
from app.ai.prompt_templates import render_prompt
|
||||
|
||||
def process_button_action(session_id: str, prompt_id: str, user_tier: UserTier):
|
||||
loader = ActionPromptLoader()
|
||||
session = get_session(session_id)
|
||||
character = get_character(session.character_id)
|
||||
|
||||
# Get the action
|
||||
action = loader.get_action_by_id(prompt_id)
|
||||
|
||||
# Verify availability
|
||||
if not action.is_available(user_tier, session.game_state.location_type):
|
||||
raise ValueError("Action not available")
|
||||
|
||||
# Build the AI prompt using action's template
|
||||
prompt = render_prompt(
|
||||
action.dm_prompt_template,
|
||||
character=character.to_dict(),
|
||||
game_state=session.game_state.to_dict()
|
||||
)
|
||||
|
||||
# Generate AI response
|
||||
response = narrative_generator.generate_story_response(...)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding New Actions
|
||||
|
||||
### 1. Add to YAML
|
||||
|
||||
```yaml
|
||||
- prompt_id: new_action_id
|
||||
category: explore # Must match ActionCategory enum
|
||||
display_text: My New Action
|
||||
description: What this action does
|
||||
tier_required: premium # free, basic, premium, elite
|
||||
context_filter: [town, wilderness] # Or [any] for all
|
||||
icon: star # Optional icon name
|
||||
cooldown_turns: 2 # 0 for no cooldown
|
||||
dm_prompt_template: |
|
||||
The player {{ action description }}...
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }}
|
||||
|
||||
Describe:
|
||||
- What happens
|
||||
- What they discover
|
||||
- Next steps
|
||||
|
||||
End with a clear outcome.
|
||||
```
|
||||
|
||||
### 2. Reload Actions
|
||||
|
||||
```python
|
||||
loader = ActionPromptLoader()
|
||||
loader.reload("app/data/action_prompts.yaml")
|
||||
```
|
||||
|
||||
### 3. Test
|
||||
|
||||
```python
|
||||
action = loader.get_action_by_id("new_action_id")
|
||||
assert action.is_available(UserTier.PREMIUM, LocationType.TOWN)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier Hierarchy
|
||||
|
||||
Actions are available to users at or above the required tier:
|
||||
|
||||
```
|
||||
FREE (0) < BASIC (1) < PREMIUM (2) < ELITE (3)
|
||||
```
|
||||
|
||||
- **FREE action:** Available to all tiers
|
||||
- **PREMIUM action:** Available to Premium and Elite
|
||||
- **ELITE action:** Available only to Elite
|
||||
|
||||
---
|
||||
|
||||
## Cooldown System
|
||||
|
||||
Actions with `cooldown_turns > 0` cannot be used again for that many turns.
|
||||
|
||||
```yaml
|
||||
cooldown_turns: 3 # Cannot use for 3 turns after use
|
||||
```
|
||||
|
||||
Cooldown tracking should be implemented in the session/game state.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```python
|
||||
def test_action_availability():
|
||||
action = ActionPrompt(
|
||||
prompt_id="test",
|
||||
category=ActionCategory.EXPLORE,
|
||||
display_text="Test",
|
||||
description="Test action",
|
||||
tier_required=UserTier.PREMIUM,
|
||||
context_filter=[LocationType.TOWN],
|
||||
dm_prompt_template="Test"
|
||||
)
|
||||
|
||||
# Premium action available to Elite in Town
|
||||
assert action.is_available(UserTier.ELITE, LocationType.TOWN) == True
|
||||
|
||||
# Premium action not available to Free
|
||||
assert action.is_available(UserTier.FREE, LocationType.TOWN) == False
|
||||
|
||||
# Not available in wrong location
|
||||
assert action.is_available(UserTier.ELITE, LocationType.WILDERNESS) == False
|
||||
|
||||
def test_loader():
|
||||
loader = ActionPromptLoader()
|
||||
loader.load_from_yaml("app/data/action_prompts.yaml")
|
||||
|
||||
# Free tier in town should see limited actions
|
||||
actions = loader.get_available_actions(UserTier.FREE, LocationType.TOWN)
|
||||
assert len(actions) == 2 # ask_locals, search_supplies
|
||||
|
||||
# Elite tier sees all actions for location
|
||||
elite_actions = loader.get_available_actions(UserTier.ELITE, LocationType.TOWN)
|
||||
assert len(elite_actions) > len(actions)
|
||||
```
|
||||
|
||||
### Manual Verification
|
||||
|
||||
```python
|
||||
from app.services.action_prompt_loader import ActionPromptLoader
|
||||
from app.models.action_prompt import LocationType
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
loader = ActionPromptLoader()
|
||||
loader.load_from_yaml("app/data/action_prompts.yaml")
|
||||
|
||||
print(f"Total actions: {loader.get_prompt_count()}")
|
||||
|
||||
for tier in [UserTier.FREE, UserTier.PREMIUM, UserTier.ELITE]:
|
||||
actions = loader.get_actions_by_tier(tier)
|
||||
print(f"{tier.value}: {len(actions)} actions")
|
||||
```
|
||||
538
api/docs/AI_INTEGRATION.md
Normal file
538
api/docs/AI_INTEGRATION.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# AI Integration Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Code of Conquest uses AI models for narrative generation through a unified Replicate API integration. This document covers the AI client architecture, model selection, and usage patterns.
|
||||
|
||||
**Key Components:**
|
||||
- **ReplicateClient** - Low-level API client for all AI models
|
||||
- **ModelSelector** - Tier-based model routing and configuration
|
||||
- **NarrativeGenerator** - High-level wrapper for game-specific generation
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ NarrativeGenerator │ ← High-level game API
|
||||
├─────────────────────┤
|
||||
│ ModelSelector │ ← Tier/context routing
|
||||
├─────────────────────┤
|
||||
│ ReplicateClient │ ← Unified API client
|
||||
├─────────────────────┤
|
||||
│ Replicate API │ ← All models (Llama, Claude)
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
All AI models are accessed through Replicate API for unified billing and management.
|
||||
|
||||
---
|
||||
|
||||
## Replicate Client
|
||||
|
||||
**File:** `app/ai/replicate_client.py`
|
||||
|
||||
### Supported Models
|
||||
|
||||
| Model Type | Identifier | Tier | Use Case |
|
||||
|------------|-----------|------|----------|
|
||||
| `LLAMA_3_8B` | `meta/meta-llama-3-8b-instruct` | Free | Cost-effective, good quality |
|
||||
| `CLAUDE_HAIKU` | `anthropic/claude-3.5-haiku` | Basic | Fast, high quality |
|
||||
| `CLAUDE_SONNET` | `anthropic/claude-3.5-sonnet` | Premium | Excellent quality |
|
||||
| `CLAUDE_SONNET_4` | `anthropic/claude-4.5-sonnet` | Elite | Best quality |
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from app.ai.replicate_client import ReplicateClient, ModelType
|
||||
|
||||
# Free tier - Llama (default)
|
||||
client = ReplicateClient()
|
||||
response = client.generate(
|
||||
prompt="You are a dungeon master...",
|
||||
max_tokens=256,
|
||||
temperature=0.7
|
||||
)
|
||||
print(response.text)
|
||||
print(f"Tokens: {response.tokens_used}")
|
||||
|
||||
# Paid tier - Claude models
|
||||
client = ReplicateClient(model=ModelType.CLAUDE_HAIKU)
|
||||
response = client.generate(
|
||||
prompt="Describe the tavern",
|
||||
system_prompt="You are a dungeon master"
|
||||
)
|
||||
|
||||
# Override model per-call
|
||||
response = client.generate("Test", model=ModelType.CLAUDE_SONNET)
|
||||
```
|
||||
|
||||
### Response Object
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ReplicateResponse:
|
||||
text: str # Generated text
|
||||
tokens_used: int # Approximate token count
|
||||
model: str # Model identifier
|
||||
generation_time: float # Generation time in seconds
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# Default parameters
|
||||
DEFAULT_MAX_TOKENS = 256
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
DEFAULT_TOP_P = 0.9
|
||||
DEFAULT_TIMEOUT = 30 # seconds
|
||||
|
||||
# Model-specific defaults
|
||||
MODEL_DEFAULTS = {
|
||||
ModelType.LLAMA_3_8B: {"max_tokens": 256, "temperature": 0.7},
|
||||
ModelType.CLAUDE_HAIKU: {"max_tokens": 512, "temperature": 0.8},
|
||||
ModelType.CLAUDE_SONNET: {"max_tokens": 1024, "temperature": 0.9},
|
||||
ModelType.CLAUDE_SONNET_4: {"max_tokens": 2048, "temperature": 0.9},
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
from app.ai.replicate_client import (
|
||||
ReplicateClientError, # Base error
|
||||
ReplicateAPIError, # API errors
|
||||
ReplicateRateLimitError, # Rate limiting
|
||||
ReplicateTimeoutError # Timeouts
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.generate(prompt)
|
||||
except ReplicateRateLimitError:
|
||||
# Handle rate limiting (client retries automatically 3 times)
|
||||
pass
|
||||
except ReplicateTimeoutError:
|
||||
# Handle timeout
|
||||
pass
|
||||
except ReplicateAPIError as e:
|
||||
# Handle other API errors
|
||||
logger.error(f"API error: {e}")
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Retry Logic**: Exponential backoff (3 retries) for rate limits
|
||||
- **Model-specific Formatting**: Llama special tokens, Claude system prompts
|
||||
- **API Key Validation**: `client.validate_api_key()` method
|
||||
|
||||
---
|
||||
|
||||
## Model Selector
|
||||
|
||||
**File:** `app/ai/model_selector.py`
|
||||
|
||||
### User Tiers
|
||||
|
||||
```python
|
||||
class UserTier(str, Enum):
|
||||
FREE = "free" # Llama 3 8B
|
||||
BASIC = "basic" # Claude Haiku
|
||||
PREMIUM = "premium" # Claude Sonnet
|
||||
ELITE = "elite" # Claude Sonnet 4
|
||||
```
|
||||
|
||||
### Context Types
|
||||
|
||||
```python
|
||||
class ContextType(str, Enum):
|
||||
STORY_PROGRESSION = "story_progression" # Creative narratives
|
||||
COMBAT_NARRATION = "combat_narration" # Action descriptions
|
||||
QUEST_SELECTION = "quest_selection" # Quest picking
|
||||
NPC_DIALOGUE = "npc_dialogue" # Character conversations
|
||||
SIMPLE_RESPONSE = "simple_response" # Quick responses
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
from app.ai.model_selector import ModelSelector, UserTier, ContextType
|
||||
|
||||
selector = ModelSelector()
|
||||
|
||||
# Select model configuration
|
||||
config = selector.select_model(
|
||||
user_tier=UserTier.PREMIUM,
|
||||
context_type=ContextType.STORY_PROGRESSION
|
||||
)
|
||||
|
||||
print(config.model_type) # ModelType.CLAUDE_SONNET
|
||||
print(config.max_tokens) # 1024
|
||||
print(config.temperature) # 0.9
|
||||
```
|
||||
|
||||
### Token Limits by Tier
|
||||
|
||||
| Tier | Base Tokens | Model |
|
||||
|------|-------------|-------|
|
||||
| FREE | 256 | Llama 3 8B |
|
||||
| BASIC | 512 | Claude Haiku |
|
||||
| PREMIUM | 1024 | Claude Sonnet |
|
||||
| ELITE | 2048 | Claude Sonnet 4 |
|
||||
|
||||
### Context Adjustments
|
||||
|
||||
**Temperature by Context:**
|
||||
- Story Progression: 0.9 (creative)
|
||||
- Combat Narration: 0.8 (exciting)
|
||||
- Quest Selection: 0.5 (deterministic)
|
||||
- NPC Dialogue: 0.85 (natural)
|
||||
- Simple Response: 0.7 (balanced)
|
||||
|
||||
**Token Multipliers:**
|
||||
- Story Progression: 1.0× (full allocation)
|
||||
- Combat Narration: 0.75× (shorter)
|
||||
- Quest Selection: 0.5× (brief)
|
||||
- NPC Dialogue: 0.75× (conversational)
|
||||
- Simple Response: 0.5× (quick)
|
||||
|
||||
### Cost Estimation
|
||||
|
||||
```python
|
||||
# Get tier information
|
||||
info = selector.get_tier_info(UserTier.PREMIUM)
|
||||
# {
|
||||
# "tier": "premium",
|
||||
# "model": "anthropic/claude-3.5-sonnet",
|
||||
# "model_name": "Claude 3.5 Sonnet",
|
||||
# "base_tokens": 1024,
|
||||
# "quality": "Excellent quality, detailed narratives"
|
||||
# }
|
||||
|
||||
# Estimate cost per request
|
||||
cost = selector.estimate_cost_per_request(UserTier.PREMIUM)
|
||||
# ~$0.009 per request
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Narrative Generator
|
||||
|
||||
**File:** `app/ai/narrative_generator.py`
|
||||
|
||||
High-level wrapper that coordinates model selection, prompt templates, and AI generation.
|
||||
|
||||
### Initialization
|
||||
|
||||
```python
|
||||
from app.ai.narrative_generator import NarrativeGenerator
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
generator = NarrativeGenerator()
|
||||
```
|
||||
|
||||
### Story Response Generation
|
||||
|
||||
```python
|
||||
response = generator.generate_story_response(
|
||||
character={
|
||||
"name": "Aldric",
|
||||
"level": 3,
|
||||
"player_class": "Fighter",
|
||||
"stats": {"strength": 16, "dexterity": 14, ...}
|
||||
},
|
||||
action="I search the room for hidden doors",
|
||||
game_state={
|
||||
"current_location": "Ancient Library",
|
||||
"location_type": "DUNGEON",
|
||||
"active_quests": ["find_artifact"]
|
||||
},
|
||||
user_tier=UserTier.PREMIUM,
|
||||
conversation_history=[
|
||||
{"turn": 1, "action": "entered library", "dm_response": "..."},
|
||||
{"turn": 2, "action": "examined shelves", "dm_response": "..."}
|
||||
],
|
||||
action_instructions="""
|
||||
The player searches for supplies. This means:
|
||||
- Describe what they FIND, not auto-purchase
|
||||
- List items with PRICES if applicable
|
||||
- Ask what they want to do with findings
|
||||
""" # Optional: from action_prompts.yaml dm_prompt_template
|
||||
)
|
||||
|
||||
print(response.narrative)
|
||||
print(f"Tokens: {response.tokens_used}")
|
||||
print(f"Model: {response.model}")
|
||||
print(f"Time: {response.generation_time:.2f}s")
|
||||
```
|
||||
|
||||
### Action Instructions
|
||||
|
||||
The `action_instructions` parameter passes action-specific guidance from `action_prompts.yaml` to the AI. This ensures:
|
||||
|
||||
1. **Player agency** - AI presents options rather than making decisions
|
||||
2. **Action semantics** - "Search" means find options, not auto-buy
|
||||
3. **Context-aware responses** - Different instructions for different actions
|
||||
|
||||
The instructions are injected into the prompt template and include critical player agency rules:
|
||||
- Never auto-purchase items
|
||||
- Never complete transactions without consent
|
||||
- Present choices and ask what they want to do
|
||||
|
||||
### Combat Narration
|
||||
|
||||
```python
|
||||
response = generator.generate_combat_narration(
|
||||
character={"name": "Aldric", ...},
|
||||
combat_state={
|
||||
"round_number": 3,
|
||||
"enemies": [{"name": "Goblin", "hp": 5, "max_hp": 10}],
|
||||
"terrain": "cave"
|
||||
},
|
||||
action="swings their sword at the goblin",
|
||||
action_result={
|
||||
"hit": True,
|
||||
"damage": 12,
|
||||
"effects": ["bleeding"]
|
||||
},
|
||||
user_tier=UserTier.BASIC,
|
||||
is_critical=True,
|
||||
is_finishing_blow=True
|
||||
)
|
||||
```
|
||||
|
||||
### Quest Selection
|
||||
|
||||
```python
|
||||
quest_id = generator.generate_quest_selection(
|
||||
character={"name": "Aldric", "level": 3, ...},
|
||||
eligible_quests=[
|
||||
{"quest_id": "goblin_cave", "name": "Clear the Cave", ...},
|
||||
{"quest_id": "herb_gathering", "name": "Gather Herbs", ...}
|
||||
],
|
||||
game_context={
|
||||
"current_location": "Tavern",
|
||||
"recent_events": ["talked to locals"]
|
||||
},
|
||||
user_tier=UserTier.FREE,
|
||||
recent_actions=["asked about rumors", "ordered ale"]
|
||||
)
|
||||
print(quest_id) # "goblin_cave"
|
||||
```
|
||||
|
||||
### NPC Dialogue
|
||||
|
||||
```python
|
||||
response = generator.generate_npc_dialogue(
|
||||
character={"name": "Aldric", ...},
|
||||
npc={
|
||||
"name": "Old Barkeep",
|
||||
"role": "Tavern Owner",
|
||||
"personality": "gruff but kind"
|
||||
},
|
||||
conversation_topic="What rumors have you heard lately?",
|
||||
game_state={"current_location": "The Rusty Anchor", ...},
|
||||
user_tier=UserTier.PREMIUM,
|
||||
npc_knowledge=["goblin attacks", "missing merchant"]
|
||||
)
|
||||
```
|
||||
|
||||
### Response Object
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class NarrativeResponse:
|
||||
narrative: str # Generated text
|
||||
tokens_used: int # Token count
|
||||
model: str # Model used
|
||||
context_type: str # Type of generation
|
||||
generation_time: float
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
from app.ai.narrative_generator import NarrativeGeneratorError
|
||||
|
||||
try:
|
||||
response = generator.generate_story_response(...)
|
||||
except NarrativeGeneratorError as e:
|
||||
logger.error(f"Generation failed: {e}")
|
||||
# Handle gracefully (show error to user, use fallback, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt Templates
|
||||
|
||||
**File:** `app/ai/prompt_templates.py`
|
||||
**Templates:** `app/ai/templates/*.j2`
|
||||
|
||||
### Available Templates
|
||||
|
||||
1. **story_action.j2** - Story progression turns
|
||||
2. **combat_action.j2** - Combat narration
|
||||
3. **quest_offering.j2** - Context-aware quest selection
|
||||
4. **npc_dialogue.j2** - NPC conversations
|
||||
|
||||
### Template Filters
|
||||
|
||||
- `format_inventory` - Format item lists
|
||||
- `format_stats` - Format character stats
|
||||
- `format_skills` - Format skill lists
|
||||
- `format_effects` - Format active effects
|
||||
- `truncate_text` - Truncate with ellipsis
|
||||
- `format_gold` - Format currency
|
||||
|
||||
### Direct Template Usage
|
||||
|
||||
```python
|
||||
from app.ai.prompt_templates import get_prompt_templates
|
||||
|
||||
templates = get_prompt_templates()
|
||||
|
||||
prompt = templates.render(
|
||||
"story_action.j2",
|
||||
character={"name": "Aldric", ...},
|
||||
action="search for traps",
|
||||
game_state={...},
|
||||
conversation_history=[...]
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Required
|
||||
REPLICATE_API_TOKEN=r8_...
|
||||
|
||||
# Optional (defaults shown)
|
||||
REPLICATE_MODEL=meta/meta-llama-3-8b-instruct
|
||||
```
|
||||
|
||||
### Cost Management
|
||||
|
||||
Approximate costs per 1K tokens:
|
||||
|
||||
| Model | Input | Output |
|
||||
|-------|-------|--------|
|
||||
| Llama 3 8B | Free | Free |
|
||||
| Claude Haiku | $0.001 | $0.005 |
|
||||
| Claude Sonnet | $0.003 | $0.015 |
|
||||
| Claude Sonnet 4 | $0.015 | $0.075 |
|
||||
|
||||
---
|
||||
|
||||
## Integration with Background Jobs
|
||||
|
||||
AI generation runs asynchronously via RQ jobs. See `app/tasks/ai_tasks.py`.
|
||||
|
||||
```python
|
||||
from app.tasks.ai_tasks import enqueue_ai_task
|
||||
|
||||
# Queue a story action
|
||||
job = enqueue_ai_task(
|
||||
task_type="narrative",
|
||||
user_id="user_123",
|
||||
context={
|
||||
"session_id": "sess_789",
|
||||
"character_id": "char_456",
|
||||
"action": "I explore the tavern"
|
||||
}
|
||||
)
|
||||
# Returns: {"job_id": "abc-123", "status": "queued"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Tracking
|
||||
|
||||
All AI calls are automatically logged for cost monitoring. See `app/services/usage_tracking_service.py`.
|
||||
|
||||
```python
|
||||
from app.services.usage_tracking_service import UsageTrackingService
|
||||
|
||||
tracker = UsageTrackingService()
|
||||
|
||||
# Get daily usage
|
||||
usage = tracker.get_daily_usage("user_123", date.today())
|
||||
print(f"Requests: {usage.total_requests}")
|
||||
print(f"Cost: ${usage.estimated_cost:.4f}")
|
||||
|
||||
# Get monthly cost
|
||||
monthly = tracker.get_monthly_cost("user_123", 2025, 11)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Tier-based daily limits enforced via `app/services/rate_limiter_service.py`.
|
||||
|
||||
### AI Calls (Turns)
|
||||
|
||||
| Tier | Daily Limit |
|
||||
|------|------------|
|
||||
| FREE | 20 turns |
|
||||
| BASIC | 50 turns |
|
||||
| PREMIUM | 100 turns |
|
||||
| ELITE | 200 turns |
|
||||
|
||||
### Custom Actions
|
||||
|
||||
Free-text player actions (beyond preset buttons) have separate limits:
|
||||
|
||||
| Tier | Custom Actions/Day | Max Characters |
|
||||
|------|-------------------|----------------|
|
||||
| FREE | 10 | 150 |
|
||||
| BASIC | 50 | 300 |
|
||||
| PREMIUM | Unlimited | 500 |
|
||||
| ELITE | Unlimited | 500 |
|
||||
|
||||
These are configurable in `config/*.yaml` under `rate_limiting.tiers.{tier}.custom_actions_per_day` and `custom_action_char_limit`.
|
||||
|
||||
```python
|
||||
from app.services.rate_limiter_service import RateLimiterService
|
||||
|
||||
limiter = RateLimiterService()
|
||||
|
||||
try:
|
||||
limiter.check_rate_limit("user_123", UserTier.PREMIUM)
|
||||
# Process request...
|
||||
limiter.increment_usage("user_123")
|
||||
except RateLimitExceeded as e:
|
||||
# Return error to user
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always specify context type** - Helps optimize token usage and temperature
|
||||
2. **Provide conversation history** - Improves narrative coherence
|
||||
3. **Handle errors gracefully** - Show user-friendly messages
|
||||
4. **Monitor costs** - Use usage tracking service
|
||||
5. **Test with mocks first** - Use mocked clients during development
|
||||
|
||||
---
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- `scripts/verify_ai_models.py` - Test model routing and API connectivity
|
||||
- `scripts/verify_e2e_ai_generation.py` - End-to-end generation flow tests
|
||||
|
||||
```bash
|
||||
# Test model routing (no API key needed)
|
||||
python scripts/verify_ai_models.py
|
||||
|
||||
# Test with real API calls
|
||||
python scripts/verify_ai_models.py --llama --haiku --sonnet
|
||||
|
||||
# Full E2E test
|
||||
python scripts/verify_e2e_ai_generation.py --real --tier premium
|
||||
```
|
||||
2309
api/docs/API_REFERENCE.md
Normal file
2309
api/docs/API_REFERENCE.md
Normal file
File diff suppressed because it is too large
Load Diff
1864
api/docs/API_TESTING.md
Normal file
1864
api/docs/API_TESTING.md
Normal file
File diff suppressed because it is too large
Load Diff
351
api/docs/APPWRITE_SETUP.md
Normal file
351
api/docs/APPWRITE_SETUP.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# Appwrite Setup Guide
|
||||
|
||||
This guide walks you through setting up Appwrite for Code of Conquest.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Appwrite Cloud account (https://cloud.appwrite.io) OR self-hosted Appwrite instance
|
||||
- Admin access to create projects and collections
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Project
|
||||
|
||||
1. Log in to Appwrite Console
|
||||
2. Click **"Create Project"**
|
||||
3. Project Name: `Code of Conquest`
|
||||
4. Project ID: (auto-generated, save this for `.env`)
|
||||
5. Click **"Create"**
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Get API Credentials
|
||||
|
||||
1. In your project, go to **Settings**
|
||||
2. Copy the following values:
|
||||
- **Project ID** → `.env` as `APPWRITE_PROJECT_ID`
|
||||
- **API Endpoint** → `.env` as `APPWRITE_ENDPOINT` (usually `https://cloud.appwrite.io/v1`)
|
||||
3. Go to **Settings** → **API Keys**
|
||||
4. Click **"Create API Key"**
|
||||
- Name: `Backend Server`
|
||||
- Expiration: Never (or long-term)
|
||||
- Scopes: Select ALL scopes (for development)
|
||||
5. Copy the generated API key → `.env` as `APPWRITE_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Database
|
||||
|
||||
1. Go to **Databases** in the left sidebar
|
||||
2. Click **"Create Database"**
|
||||
3. Database ID: `main`
|
||||
4. Name: `Main Database`
|
||||
5. Click **"Create"**
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Collections
|
||||
|
||||
Create the following collections in the `main` database.
|
||||
|
||||
### Collection 1: characters
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Collection ID | `characters` |
|
||||
| Collection Name | `Characters` |
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Key | Type | Size | Required | Default | Array |
|
||||
|-----|------|------|----------|---------|-------|
|
||||
| `userId` | String | 255 | Yes | - | No |
|
||||
| `characterData` | String | 100000 | Yes | - | No |
|
||||
| `created_at` | DateTime | - | Yes | - | No |
|
||||
| `updated_at` | DateTime | - | Yes | - | No |
|
||||
| `is_active` | Boolean | - | Yes | true | No |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
| Key | Type | Attributes |
|
||||
|-----|------|------------|
|
||||
| `userId_index` | Key | `userId` (ASC) |
|
||||
| `active_index` | Key | `is_active` (ASC) |
|
||||
|
||||
**Permissions:**
|
||||
- Create: `users`
|
||||
- Read: `users`
|
||||
- Update: `users`
|
||||
- Delete: `users`
|
||||
|
||||
### Collection 2: game_sessions
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Collection ID | `game_sessions` |
|
||||
| Collection Name | `Game Sessions` |
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Key | Type | Size | Required | Default | Array |
|
||||
|-----|------|------|----------|---------|-------|
|
||||
| `party_member_ids` | String | 255 | Yes | - | Yes |
|
||||
| `config` | String | 5000 | Yes | - | No |
|
||||
| `combat_encounter` | String | 50000 | No | - | No |
|
||||
| `conversation_history` | String | 500000 | Yes | - | No |
|
||||
| `game_state` | String | 50000 | Yes | - | No |
|
||||
| `turn_order` | String | 255 | Yes | - | Yes |
|
||||
| `current_turn` | Integer | - | Yes | 0 | No |
|
||||
| `turn_number` | Integer | - | Yes | 1 | No |
|
||||
| `created_at` | DateTime | - | Yes | - | No |
|
||||
| `last_activity` | DateTime | - | Yes | - | No |
|
||||
| `status` | String | 50 | Yes | active | No |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
| Key | Type | Attributes |
|
||||
|-----|------|------------|
|
||||
| `status_index` | Key | `status` (ASC) |
|
||||
| `last_activity_index` | Key | `last_activity` (DESC) |
|
||||
|
||||
**Permissions:**
|
||||
- Create: `users`
|
||||
- Read: `users`
|
||||
- Update: `users`
|
||||
- Delete: `users`
|
||||
|
||||
### Collection 3: marketplace_listings
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Collection ID | `marketplace_listings` |
|
||||
| Collection Name | `Marketplace Listings` |
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Key | Type | Size | Required | Default | Array |
|
||||
|-----|------|------|----------|---------|-------|
|
||||
| `seller_id` | String | 255 | Yes | - | No |
|
||||
| `character_id` | String | 255 | Yes | - | No |
|
||||
| `item_data` | String | 10000 | Yes | - | No |
|
||||
| `listing_type` | String | 50 | Yes | - | No |
|
||||
| `price` | Integer | - | No | - | No |
|
||||
| `starting_bid` | Integer | - | No | - | No |
|
||||
| `current_bid` | Integer | - | No | - | No |
|
||||
| `buyout_price` | Integer | - | No | - | No |
|
||||
| `bids` | String | 50000 | No | - | No |
|
||||
| `auction_end` | DateTime | - | No | - | No |
|
||||
| `status` | String | 50 | Yes | active | No |
|
||||
| `created_at` | DateTime | - | Yes | - | No |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
| Key | Type | Attributes |
|
||||
|-----|------|------------|
|
||||
| `listing_type_index` | Key | `listing_type` (ASC) |
|
||||
| `status_index` | Key | `status` (ASC) |
|
||||
| `seller_index` | Key | `seller_id` (ASC) |
|
||||
| `auction_end_index` | Key | `auction_end` (ASC) |
|
||||
|
||||
**Permissions:**
|
||||
- Create: `users`
|
||||
- Read: `any` (public can browse)
|
||||
- Update: `users` (owner only, enforced in code)
|
||||
- Delete: `users` (owner only, enforced in code)
|
||||
|
||||
### Collection 4: transactions
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Collection ID | `transactions` |
|
||||
| Collection Name | `Transactions` |
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Key | Type | Size | Required | Default | Array |
|
||||
|-----|------|------|----------|---------|-------|
|
||||
| `buyer_id` | String | 255 | Yes | - | No |
|
||||
| `seller_id` | String | 255 | Yes | - | No |
|
||||
| `listing_id` | String | 255 | No | - | No |
|
||||
| `item_data` | String | 10000 | Yes | - | No |
|
||||
| `price` | Integer | - | Yes | - | No |
|
||||
| `timestamp` | DateTime | - | Yes | - | No |
|
||||
| `transaction_type` | String | 50 | Yes | - | No |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
| Key | Type | Attributes |
|
||||
|-----|------|------------|
|
||||
| `buyer_index` | Key | `buyer_id` (ASC) |
|
||||
| `seller_index` | Key | `seller_id` (ASC) |
|
||||
| `timestamp_index` | Key | `timestamp` (DESC) |
|
||||
|
||||
**Permissions:**
|
||||
- Create: System only (API key)
|
||||
- Read: `users` (buyer/seller only, enforced in code)
|
||||
- Update: None
|
||||
- Delete: None
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Enable Realtime
|
||||
|
||||
1. Go to **Settings** → **Realtime**
|
||||
2. Enable **Realtime API**
|
||||
3. Add allowed origins:
|
||||
- `http://localhost:5000` (development)
|
||||
- Your production domain (when ready)
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Configure Authentication
|
||||
|
||||
1. Go to **Auth** in the left sidebar
|
||||
2. Enable **Email/Password** authentication
|
||||
3. Configure password requirements:
|
||||
- Minimum length: 8
|
||||
- Require lowercase: Yes
|
||||
- Require uppercase: Yes
|
||||
- Require numbers: Yes
|
||||
- Require special characters: Optional
|
||||
|
||||
**Optional:** Enable OAuth providers if desired (Google, GitHub, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Update .env File
|
||||
|
||||
Copy `.env.example` to `.env` and fill in the values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Update the following in `.env`:
|
||||
|
||||
```bash
|
||||
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
APPWRITE_PROJECT_ID=your-project-id-here
|
||||
APPWRITE_API_KEY=your-api-key-here
|
||||
APPWRITE_DATABASE_ID=main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Test Connection
|
||||
|
||||
Create a test script to verify Appwrite connection:
|
||||
|
||||
**test_appwrite.py:**
|
||||
|
||||
```python
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.databases import Databases
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Initialize Appwrite client
|
||||
client = Client()
|
||||
client.set_endpoint(os.getenv('APPWRITE_ENDPOINT'))
|
||||
client.set_project(os.getenv('APPWRITE_PROJECT_ID'))
|
||||
client.set_key(os.getenv('APPWRITE_API_KEY'))
|
||||
|
||||
# Test database connection
|
||||
databases = Databases(client)
|
||||
|
||||
try:
|
||||
# List collections
|
||||
result = databases.list_collections(
|
||||
database_id=os.getenv('APPWRITE_DATABASE_ID')
|
||||
)
|
||||
print(f"✓ Connected to Appwrite successfully!")
|
||||
print(f"✓ Found {result['total']} collections:")
|
||||
for collection in result['collections']:
|
||||
print(f" - {collection['name']} (ID: {collection['$id']})")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to connect to Appwrite:")
|
||||
print(f" Error: {str(e)}")
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
pip install appwrite python-dotenv
|
||||
python test_appwrite.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Production Permissions
|
||||
|
||||
**For production, tighten permissions:**
|
||||
|
||||
1. **characters collection:**
|
||||
- Users can only access their own characters (enforce in code)
|
||||
- Add server-side checks for `userId` match
|
||||
|
||||
2. **game_sessions collection:**
|
||||
- Party members can read (enforce in code)
|
||||
- Active player can write (enforce in code)
|
||||
|
||||
3. **marketplace_listings collection:**
|
||||
- Anyone can read (browsing)
|
||||
- Only owner can update/delete (enforce in code)
|
||||
|
||||
4. **transactions collection:**
|
||||
- Create via API key only
|
||||
- Users can read only their own transactions (enforce in code)
|
||||
|
||||
### API Key Security
|
||||
|
||||
- **Never commit** `.env` file to git
|
||||
- Use different API keys for dev/staging/production
|
||||
- Rotate API keys periodically
|
||||
- Use minimal scopes in production (not "all")
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue:** "Project not found"
|
||||
- Solution: Verify `APPWRITE_PROJECT_ID` matches the project ID in Appwrite Console
|
||||
|
||||
**Issue:** "Invalid API key"
|
||||
- Solution: Regenerate API key and update `.env`
|
||||
|
||||
**Issue:** "Collection not found"
|
||||
- Solution: Verify collection IDs match exactly (case-sensitive)
|
||||
|
||||
**Issue:** "Permission denied"
|
||||
- Solution: Check collection permissions in Appwrite Console
|
||||
|
||||
**Issue:** "Realtime not working"
|
||||
- Solution: Verify Realtime is enabled and origins are configured
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
After completing Appwrite setup:
|
||||
|
||||
1. ✅ Install dependencies: `pip install -r requirements.txt`
|
||||
2. ✅ Test connection with `test_appwrite.py`
|
||||
3. ✅ Create Appwrite service wrapper (`app/services/appwrite_service.py`)
|
||||
4. Start building API endpoints (Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Appwrite Documentation:** https://appwrite.io/docs
|
||||
- **Appwrite Python SDK:** https://github.com/appwrite/sdk-for-python
|
||||
- **Appwrite Console:** https://cloud.appwrite.io
|
||||
- **Appwrite Discord:** https://appwrite.io/discord
|
||||
1182
api/docs/DATA_MODELS.md
Normal file
1182
api/docs/DATA_MODELS.md
Normal file
File diff suppressed because it is too large
Load Diff
587
api/docs/GAME_SYSTEMS.md
Normal file
587
api/docs/GAME_SYSTEMS.md
Normal file
@@ -0,0 +1,587 @@
|
||||
# Game Systems
|
||||
|
||||
## Combat System
|
||||
|
||||
### Core Concepts
|
||||
|
||||
**Turn-Based Combat:**
|
||||
- Initiative rolls determine turn order (d20 + speed stat)
|
||||
- Each combatant takes one action per turn
|
||||
- Effects (buffs/debuffs/DoT) process at start of turn
|
||||
- Combat continues until one side is defeated
|
||||
|
||||
### Damage Calculations
|
||||
|
||||
| Attack Type | Formula | Min Damage |
|
||||
|-------------|---------|------------|
|
||||
| **Physical** | weapon.damage + (strength / 2) - target.defense | 1 |
|
||||
| **Magical** | spell.damage + (magic_power / 2) - target.resistance | 1 |
|
||||
| **Critical Hit** | base_damage × weapon.crit_multiplier | - |
|
||||
|
||||
**Critical Hit System:**
|
||||
- **Design Choice:** Critical hits only, no damage variance (JRPG-style)
|
||||
- Base damage is **deterministic** (always same result for same stats)
|
||||
- Random element is **only** whether attack crits
|
||||
- Default crit_chance: **5%** (0.05)
|
||||
- Default crit_multiplier: **2.0×** (double damage)
|
||||
- Weapons can have different crit_chance and crit_multiplier values
|
||||
|
||||
**Why This Design:**
|
||||
- Predictable damage for tactical planning
|
||||
- Exciting moments when crits occur
|
||||
- Easier to balance than full damage ranges
|
||||
- Simpler AI prompting (no damage variance to explain)
|
||||
|
||||
### Combat Flow
|
||||
|
||||
| Phase | Actions |
|
||||
|-------|---------|
|
||||
| **1. Initialize Combat** | Roll initiative for all combatants<br>Sort by initiative (highest first)<br>Set turn order |
|
||||
| **2. Turn Start** | Process all active effects on current combatant<br>Check for stun (skip turn if stunned)<br>Reduce spell cooldowns |
|
||||
| **3. Action Phase** | Player/AI selects action (attack, cast spell, use item, defend)<br>Execute action<br>Apply damage/effects<br>Check for death |
|
||||
| **4. Turn End** | Advance to next combatant<br>If back to first combatant, increment round number<br>Check for combat end condition |
|
||||
| **5. Combat Resolution** | **Victory:** Distribute XP and loot<br>**Defeat:** Handle character death/respawn |
|
||||
|
||||
### Action Types
|
||||
|
||||
| Action | Description | Examples |
|
||||
|--------|-------------|----------|
|
||||
| **Attack** | Physical weapon attack | Sword strike, bow shot |
|
||||
| **Cast Spell** | Use magical ability | Fireball, heal, curse |
|
||||
| **Use Item** | Consume item from inventory | Health potion, scroll |
|
||||
| **Defend** | Defensive stance | +defense for 1 turn |
|
||||
| **Special Ability** | Class-specific skill | Shield bash, stealth strike |
|
||||
|
||||
### Effect Mechanics
|
||||
|
||||
**Effect Processing:**
|
||||
- Effects have **duration** (turns remaining)
|
||||
- Effects can **stack** (multiple applications increase power)
|
||||
- Effects are processed at **start of turn**
|
||||
- Effects expire automatically when duration reaches 0
|
||||
|
||||
**Stacking Mechanics:**
|
||||
- Effects stack up to **max_stacks** (default 5, configurable per effect)
|
||||
- Re-applying same effect increases stacks (up to max)
|
||||
- Duration **refreshes** on re-application (does not stack cumulatively)
|
||||
- Power scales linearly with stacks: 3 stacks × 5 damage = 15 damage per turn
|
||||
- Once at max stacks, re-application only refreshes duration
|
||||
|
||||
**Stacking Examples:**
|
||||
- Poison (power=5, max_stacks=5): 3 stacks = 15 damage per turn
|
||||
- Defense buff (power=3, max_stacks=5): 2 stacks = +6 defense
|
||||
- Applying poison 6 times = 5 stacks (capped), duration refreshed each time
|
||||
|
||||
**Shield Effect Mechanics:**
|
||||
- Shield absorbs damage **before HP loss**
|
||||
- Shield strength = power × stacks
|
||||
- Partial absorption: If damage > shield, shield breaks and remaining damage goes to HP
|
||||
- Full absorption: If damage <= shield, all damage absorbed, shield reduced
|
||||
- Shield depletes when power reaches 0 or duration expires
|
||||
|
||||
**Effect Interaction with Stats:**
|
||||
- BUFF/DEBUFF effects modify stats via `get_effective_stats()`
|
||||
- Stat modifications are temporary (only while effect is active)
|
||||
- Debuffs **cannot reduce stats below 1** (minimum clamping)
|
||||
- Buffs stack with equipment and skill bonuses
|
||||
|
||||
### AI-Generated Combat Narrative
|
||||
|
||||
**Narrative Generation:**
|
||||
- After each combat action executes, generate narrative description
|
||||
- Code calculates mechanics ("Aragorn attacks Goblin for 15 damage (critical hit!)")
|
||||
- AI generates flavor ("Aragorn's blade finds a gap in the goblin's armor, striking a devastating blow!")
|
||||
|
||||
**Model Selection:**
|
||||
|
||||
| Encounter Type | Model Tier |
|
||||
|----------------|------------|
|
||||
| Standard encounters | STANDARD (Haiku) |
|
||||
| Boss fights | PREMIUM (Sonnet) |
|
||||
| Free tier users | FREE (Replicate) |
|
||||
|
||||
---
|
||||
|
||||
## Ability System
|
||||
|
||||
### Overview
|
||||
|
||||
Abilities are actions that can be used in combat (attacks, spells, skills). The system is **data-driven** using YAML configuration files.
|
||||
|
||||
### Ability Components
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| **Base Power** | Starting damage or healing value |
|
||||
| **Scaling Stat** | Which stat enhances the ability (STR, INT, etc.) |
|
||||
| **Scaling Factor** | Multiplier for scaling (default 0.5) |
|
||||
| **Mana Cost** | MP required to use |
|
||||
| **Cooldown** | Turns before ability can be used again |
|
||||
| **Effects** | Status effects applied on hit |
|
||||
|
||||
### Power Calculation
|
||||
|
||||
```
|
||||
Final Power = base_power + (scaling_stat × scaling_factor)
|
||||
Minimum power is always 1
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- **Cleave** (physical skill): base_power=15, scaling_stat=STRENGTH, scaling_factor=0.5
|
||||
- With 20 STR: 15 + (20 × 0.5) = 25 power
|
||||
- **Fireball** (spell): base_power=30, scaling_stat=INTELLIGENCE, scaling_factor=0.5
|
||||
- With 16 INT: 30 + (16 × 0.5) = 38 power
|
||||
|
||||
### Mana & Cooldown Mechanics
|
||||
|
||||
**Mana System:**
|
||||
- Each ability has a mana_cost (0 for basic attacks)
|
||||
- Combatant must have current_mp >= mana_cost
|
||||
- Mana is consumed when ability is used
|
||||
- Mana regeneration happens between combat encounters
|
||||
|
||||
**Cooldown System:**
|
||||
- Abilities can have cooldowns (turns before re-use)
|
||||
- Cooldown starts when ability is used
|
||||
- Cooldowns tick down at start of each turn
|
||||
- 0 cooldown = can use every turn
|
||||
|
||||
**Example:**
|
||||
- Power Strike: mana_cost=10, cooldown=3
|
||||
- Use on turn 1 → Can't use again until turn 5
|
||||
|
||||
### Effect Application
|
||||
|
||||
Abilities can apply effects to targets:
|
||||
|
||||
```yaml
|
||||
effects_applied:
|
||||
- effect_id: "burn"
|
||||
name: "Burning"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 5
|
||||
max_stacks: 3
|
||||
```
|
||||
|
||||
When ability hits, all `effects_applied` are added to the target's `active_effects`.
|
||||
|
||||
### Data-Driven Design
|
||||
|
||||
**Benefits:**
|
||||
- Game designers can add/modify abilities without code changes
|
||||
- Easy balancing through config file edits
|
||||
- Version control friendly (text files)
|
||||
- Hot-reloading capable (reload without restart)
|
||||
|
||||
**Workflow:**
|
||||
1. Create YAML file in `/app/data/abilities/`
|
||||
2. Define ability properties
|
||||
3. AbilityLoader automatically loads on request
|
||||
4. Abilities available for use immediately
|
||||
|
||||
**Example YAML Structure:**
|
||||
```yaml
|
||||
ability_id: "shield_bash"
|
||||
name: "Shield Bash"
|
||||
description: "Bash enemy with shield, dealing damage and stunning"
|
||||
ability_type: "skill"
|
||||
base_power: 10
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 5
|
||||
cooldown: 2
|
||||
effects_applied:
|
||||
- effect_id: "stun_1"
|
||||
name: "Stunned"
|
||||
effect_type: "stun"
|
||||
duration: 1
|
||||
power: 0
|
||||
```
|
||||
|
||||
### Ability Types
|
||||
|
||||
| Type | Description | Typical Use |
|
||||
|------|-------------|-------------|
|
||||
| **ATTACK** | Basic physical attack | Default melee/ranged attacks |
|
||||
| **SPELL** | Magical ability | Fireballs, heals, buffs |
|
||||
| **SKILL** | Class-specific ability | Shield bash, backstab, power strike |
|
||||
| **ITEM_USE** | Using consumable | Health potion, scroll |
|
||||
| **DEFEND** | Defensive action | Defensive stance, dodge |
|
||||
|
||||
---
|
||||
|
||||
## Multiplayer Party System
|
||||
|
||||
### Session Formation
|
||||
|
||||
| Step | Action | Details |
|
||||
|------|--------|---------|
|
||||
| 1 | Create Session | Leader creates session with configuration |
|
||||
| 2 | Generate Code | System generates invite code |
|
||||
| 3 | Join Session | Other players join via invite code |
|
||||
| 4 | Start Game | Session begins when min_players met |
|
||||
|
||||
**Max Party Size by Tier:**
|
||||
|
||||
| Subscription Tier | Max Party Size |
|
||||
|-------------------|----------------|
|
||||
| FREE | Solo only (1) |
|
||||
| BASIC | 2 players |
|
||||
| PREMIUM | 6 players |
|
||||
| ELITE | 10 players |
|
||||
|
||||
### Turn Flow
|
||||
|
||||
1. Turn order determined by initiative
|
||||
2. Active player takes action
|
||||
3. Action queued to RQ for AI processing
|
||||
4. AI response generated
|
||||
5. Game state updated in Appwrite
|
||||
6. All party members notified via Appwrite Realtime
|
||||
7. Next player's turn
|
||||
|
||||
### Session End Conditions
|
||||
|
||||
| Condition | Result |
|
||||
|-----------|--------|
|
||||
| Manual end by leader | Session completed |
|
||||
| Below min_players for timeout duration | Session timeout |
|
||||
| All players leave | Session completed |
|
||||
| Total party wipeout in combat | Session completed (defeat) |
|
||||
|
||||
### Post-Session
|
||||
|
||||
- Players keep all loot/gold earned
|
||||
- Session logs saved:
|
||||
- **Free tier:** 7 days
|
||||
- **Basic:** 14 days
|
||||
- **Premium:** 30 days
|
||||
- **Elite:** 90 days
|
||||
- Exportable as Markdown
|
||||
|
||||
### Realtime Synchronization
|
||||
|
||||
**Appwrite Realtime Features:**
|
||||
- WebSocket connections for multiplayer
|
||||
- Automatic updates when game state changes
|
||||
- No polling required
|
||||
- Built-in connection management
|
||||
- Automatic reconnection
|
||||
|
||||
**Update Flow:**
|
||||
1. Player takes action
|
||||
2. Backend updates Appwrite document
|
||||
3. Appwrite triggers realtime event
|
||||
4. All subscribed clients receive update
|
||||
5. UI updates automatically
|
||||
|
||||
---
|
||||
|
||||
## Marketplace System
|
||||
|
||||
### Overview
|
||||
|
||||
| Aspect | Details |
|
||||
|--------|---------|
|
||||
| **Access Level** | Premium+ subscription tiers only |
|
||||
| **Currency** | In-game gold only (no real money trading) |
|
||||
| **Listing Types** | Fixed price or auction |
|
||||
| **Transaction Fee** | None (may implement later for economy balance) |
|
||||
|
||||
### Auction System
|
||||
|
||||
**eBay-Style Bidding:**
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Starting Bid** | Minimum bid set by seller |
|
||||
| **Buyout Price** | Optional instant-win price |
|
||||
| **Duration** | 24, 48, or 72 hours |
|
||||
| **Bidding** | Must exceed current bid |
|
||||
| **Auto-Win** | Buyout price triggers instant sale |
|
||||
| **Winner** | Highest bidder when auction ends |
|
||||
| **Notifications** | Outbid alerts via Appwrite Realtime |
|
||||
|
||||
**Auction Processing:**
|
||||
- RQ periodic task checks for ended auctions every 5 minutes
|
||||
- Winner receives item, seller receives gold
|
||||
- If no bids, item returned to seller
|
||||
|
||||
### Fixed Price Listings
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Price** | Set by seller |
|
||||
| **Purchase** | Immediate transaction |
|
||||
| **Availability** | First come, first served |
|
||||
|
||||
### Item Restrictions
|
||||
|
||||
**Non-Tradeable Items:**
|
||||
- Quest items
|
||||
- Character-bound items
|
||||
- Items marked `is_tradeable: false`
|
||||
|
||||
### Marketplace Features by Tier
|
||||
|
||||
| Tier | Access | Max Active Listings | Priority |
|
||||
|------|--------|---------------------|----------|
|
||||
| FREE | ✗ | - | - |
|
||||
| BASIC | ✗ | - | - |
|
||||
| PREMIUM | ✓ | 10 | Normal |
|
||||
| ELITE | ✓ | 25 | Priority (shown first) |
|
||||
|
||||
---
|
||||
|
||||
## NPC Shop System
|
||||
|
||||
### Overview
|
||||
|
||||
**Game-Run Shop:**
|
||||
- Sells basic items at fixed prices
|
||||
- Always available (not affected by marketplace access)
|
||||
- Provides gold sink to prevent inflation
|
||||
|
||||
### Shop Categories
|
||||
|
||||
| Category | Items | Tier Range |
|
||||
|----------|-------|------------|
|
||||
| **Consumables** | Health potions, mana potions, antidotes | All |
|
||||
| **Basic Weapons** | Swords, bows, staves | 1-2 |
|
||||
| **Basic Armor** | Helmets, chest plates, boots | 1-2 |
|
||||
| **Crafting Materials** | (Future feature) | - |
|
||||
|
||||
### Pricing Strategy
|
||||
|
||||
- Basic items priced reasonably for new players
|
||||
- Prices higher than marketplace average (encourages player economy)
|
||||
- No selling back to shop (or at 50% value to prevent abuse)
|
||||
|
||||
---
|
||||
|
||||
## Progression Systems
|
||||
|
||||
### Experience & Leveling
|
||||
|
||||
| Source | XP Gain |
|
||||
|--------|---------|
|
||||
| Combat victory | Based on enemy difficulty |
|
||||
| Quest completion | Fixed quest reward |
|
||||
| Story milestones | Major plot points |
|
||||
| Exploration | Discovering new locations |
|
||||
|
||||
**Level Progression:**
|
||||
- XP required increases per level (exponential curve)
|
||||
- Each level grants +1 skill point
|
||||
- Stats may increase based on class
|
||||
|
||||
### Loot System
|
||||
|
||||
**Loot Sources:**
|
||||
- Defeated enemies
|
||||
- Treasure chests
|
||||
- Quest rewards
|
||||
- Boss encounters
|
||||
|
||||
**Loot Quality Tiers:**
|
||||
|
||||
| Tier | Color | Drop Rate | Example |
|
||||
|------|-------|-----------|---------|
|
||||
| Common | Gray | 60% | Basic health potion |
|
||||
| Uncommon | Green | 25% | Enhanced sword |
|
||||
| Rare | Blue | 10% | Fire-enchanted blade |
|
||||
| Epic | Purple | 4% | Legendary armor |
|
||||
| Legendary | Orange | 1% | Artifact weapon |
|
||||
|
||||
**Boss Loot:**
|
||||
- Bosses always drop rare+ items
|
||||
- Guaranteed unique item per boss
|
||||
- Chance for legendary items
|
||||
|
||||
---
|
||||
|
||||
## Quest System (Future)
|
||||
|
||||
### Quest Types
|
||||
|
||||
| Type | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| **Main Story** | Plot progression | "Defeat the Dark Lord" |
|
||||
| **Side Quest** | Optional content | "Help the blacksmith" |
|
||||
| **Daily Quest** | Repeatable daily | "Slay 10 goblins" |
|
||||
| **World Event** | Server-wide | "Defend the city" |
|
||||
|
||||
### Quest Rewards
|
||||
|
||||
- Gold
|
||||
- Experience
|
||||
- Items (equipment, consumables)
|
||||
- Unlock locations/features
|
||||
- Reputation with factions
|
||||
|
||||
---
|
||||
|
||||
## Economy & Balance
|
||||
|
||||
### Gold Sources (Inflow)
|
||||
|
||||
| Source | Amount |
|
||||
|--------|--------|
|
||||
| Combat loot | 10-100 per encounter |
|
||||
| Quest rewards | 100-1000 per quest |
|
||||
| Marketplace sales | Player-driven |
|
||||
|
||||
### Gold Sinks (Outflow)
|
||||
|
||||
| Sink | Cost |
|
||||
|------|------|
|
||||
| NPC shop purchases | Varies |
|
||||
| Skill respec | Level × 100 gold |
|
||||
| Fast travel | 50-500 per location |
|
||||
| Equipment repairs | (Future feature) |
|
||||
|
||||
### Economy Monitoring
|
||||
|
||||
**Metrics to Track:**
|
||||
- Average gold per player
|
||||
- Marketplace price trends
|
||||
- Item availability
|
||||
- Transaction volume
|
||||
|
||||
**Balancing Actions:**
|
||||
- Adjust NPC shop prices
|
||||
- Introduce new gold sinks
|
||||
- Modify loot drop rates
|
||||
- Implement transaction fees if needed
|
||||
|
||||
---
|
||||
|
||||
## PvP Arena (Future Feature)
|
||||
|
||||
### Planned Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Arena Mode** | Optional combat mode |
|
||||
| **Matchmaking** | Ranked and casual |
|
||||
| **Rewards** | Exclusive PvP items |
|
||||
| **Leaderboard** | Season-based rankings |
|
||||
| **Restrictions** | Balanced gear/levels |
|
||||
|
||||
**Note:** PvP is entirely optional and separate from main game.
|
||||
|
||||
---
|
||||
|
||||
## Guild System (Future Feature)
|
||||
|
||||
### Planned Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Guild Creation** | Player-run organizations |
|
||||
| **Guild Bank** | Shared resources |
|
||||
| **Guild Quests** | Cooperative challenges |
|
||||
| **Guild Halls** | Customizable spaces |
|
||||
| **Guild Wars** | PvP guild vs guild |
|
||||
|
||||
---
|
||||
|
||||
## World Events (Future Feature)
|
||||
|
||||
### Planned Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Server-Wide Events** | All players can participate |
|
||||
| **Timed Events** | Limited duration |
|
||||
| **Cooperative Goals** | Community objectives |
|
||||
| **Exclusive Rewards** | Event-only items |
|
||||
| **Story Impact** | Events affect world state |
|
||||
|
||||
---
|
||||
|
||||
## Achievements (Future Feature)
|
||||
|
||||
### Planned Categories
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| **Combat** | "Defeat 100 enemies", "Win without taking damage" |
|
||||
| **Exploration** | "Discover all locations", "Travel 1000 miles" |
|
||||
| **Collection** | "Collect all legendary items", "Complete skill tree" |
|
||||
| **Social** | "Join a guild", "Complete 10 multiplayer sessions" |
|
||||
| **Story** | "Complete main story", "Complete all side quests" |
|
||||
|
||||
**Rewards:**
|
||||
- Titles
|
||||
- Cosmetic items
|
||||
- Special abilities
|
||||
- Achievement points
|
||||
|
||||
---
|
||||
|
||||
## Crafting System (Future Feature)
|
||||
|
||||
### Planned Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Recipes** | Learn from quests, loot, NPCs |
|
||||
| **Materials** | Gather from enemies, exploration |
|
||||
| **Crafting Stations** | Special locations required |
|
||||
| **Item Enhancement** | Upgrade existing items |
|
||||
| **Unique Items** | Crafted-only items |
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### AI Cost Tracking
|
||||
|
||||
**Log every AI call:**
|
||||
- User ID
|
||||
- Model used
|
||||
- Cost tier
|
||||
- Tokens used
|
||||
- Timestamp
|
||||
|
||||
**Daily Limits:**
|
||||
- Track usage per user per day
|
||||
- Block calls if limit exceeded
|
||||
- Graceful degradation message
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Consistent error format:**
|
||||
```json
|
||||
{
|
||||
"error": "Error message",
|
||||
"code": "ERROR_CODE",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
**Structured logging with context:**
|
||||
- Session ID
|
||||
- Character ID
|
||||
- Action type
|
||||
- Results
|
||||
- Timestamp
|
||||
|
||||
---
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **DM** | Dungeon Master (AI in this case) |
|
||||
| **NPC** | Non-Player Character |
|
||||
| **DoT** | Damage over Time |
|
||||
| **HoT** | Heal over Time |
|
||||
| **AoE** | Area of Effect |
|
||||
| **XP** | Experience Points |
|
||||
| **PWA** | Progressive Web App |
|
||||
807
api/docs/MULTIPLAYER.md
Normal file
807
api/docs/MULTIPLAYER.md
Normal file
@@ -0,0 +1,807 @@
|
||||
# Multiplayer System - API Backend
|
||||
|
||||
**Status:** Planned
|
||||
**Phase:** 6 (Multiplayer Sessions)
|
||||
**Timeline:** Week 12-13 (14 days)
|
||||
**Last Updated:** November 18, 2025
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Multiplayer System backend handles all business logic for time-limited co-op sessions, including session management, invite system, AI campaign generation, combat orchestration, realtime synchronization, and reward distribution.
|
||||
|
||||
**Backend Responsibilities:**
|
||||
- Session lifecycle management (create, join, start, end, expire)
|
||||
- Invite code generation and validation
|
||||
- AI-generated campaign creation
|
||||
- Turn-based combat validation and state management
|
||||
- Realtime event broadcasting via Appwrite
|
||||
- Reward calculation and distribution
|
||||
- Character snapshot management
|
||||
- Session expiration enforcement (2-hour limit)
|
||||
|
||||
---
|
||||
|
||||
## Multiplayer vs Solo Gameplay
|
||||
|
||||
| Feature | Solo Gameplay | Multiplayer Gameplay |
|
||||
|---------|---------------|----------------------|
|
||||
| **Tier Requirement** | All tiers (Free, Basic, Premium, Elite) | Premium/Elite only |
|
||||
| **Session Type** | Ongoing, persistent | Time-limited (2 hours) |
|
||||
| **Story Progression** | ✅ Full button-based actions | ❌ Limited (combat-focused) |
|
||||
| **Quests** | ✅ Context-aware quest offering | ❌ No quest system |
|
||||
| **Combat** | ✅ Full combat system | ✅ Cooperative combat |
|
||||
| **AI Narration** | ✅ Rich narrative responses | ✅ Campaign narration |
|
||||
| **Character Use** | Uses character location/state | Temporary instance (doesn't affect solo character) |
|
||||
| **Session Duration** | Unlimited | 2 hours max |
|
||||
| **Access Method** | Create anytime | Invite link required |
|
||||
|
||||
**Design Philosophy:**
|
||||
- **Solo**: Deep, persistent RPG experience with quests, exploration, character progression
|
||||
- **Multiplayer**: Drop-in co-op sessions for quick adventures with friends
|
||||
|
||||
---
|
||||
|
||||
## Session Architecture
|
||||
|
||||
### Session Types
|
||||
|
||||
The system supports two distinct session types:
|
||||
|
||||
#### Solo Session
|
||||
- Created via `POST /api/v1/sessions` with `session_type: "solo"`
|
||||
- Single player character
|
||||
- Persistent across logins
|
||||
- Full story progression and quest systems
|
||||
- No time limit
|
||||
|
||||
#### Multiplayer Session
|
||||
- Created via `POST /api/v1/sessions/multiplayer` with invite link
|
||||
- 2-4 player characters (configurable)
|
||||
- Time-limited (2 hour duration)
|
||||
- Combat and short campaign focus
|
||||
- Realtime synchronization required
|
||||
|
||||
### Multiplayer Session Lifecycle
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Host Creates Session │
|
||||
│ - Premium/Elite tier required │
|
||||
│ - Select difficulty and party size (2-4 players) │
|
||||
│ - Generate invite link │
|
||||
└────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Invite Link Shared │
|
||||
│ - Host shares link with friends │
|
||||
│ - Link valid for 24 hours or until session starts │
|
||||
└────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Players Join Session │
|
||||
│ - Click invite link │
|
||||
│ - Select character to use (from their characters) │
|
||||
│ - Wait in lobby for all players │
|
||||
└────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Host Starts Session │
|
||||
│ - All players ready in lobby │
|
||||
│ - AI generates custom campaign │
|
||||
│ - 2-hour timer begins │
|
||||
└────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Gameplay Loop │
|
||||
│ - Turn-based cooperative combat │
|
||||
│ - AI narration and encounters │
|
||||
│ - Shared loot and rewards │
|
||||
│ - Realtime updates for all players │
|
||||
└────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Session Ends │
|
||||
│ - 2 hour timer expires OR │
|
||||
│ - Party completes campaign OR │
|
||||
│ - Party wipes in combat OR │
|
||||
│ - Host manually ends session │
|
||||
└────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Rewards Distributed │
|
||||
│ - XP, gold, items granted to characters │
|
||||
│ - Characters saved back to player accounts │
|
||||
│ - Session archived (read-only history) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### MultiplayerSession
|
||||
|
||||
Extends GameSession with multiplayer-specific fields.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MultiplayerSession:
|
||||
"""Represents a time-limited multiplayer co-op session."""
|
||||
|
||||
session_id: str # Unique identifier
|
||||
host_user_id: str # User who created the session
|
||||
invite_code: str # Shareable invite link code
|
||||
session_type: str = "multiplayer" # Always "multiplayer"
|
||||
status: str = "lobby" # "lobby", "active", "completed", "expired"
|
||||
|
||||
# Party configuration
|
||||
max_players: int = 4 # 2-4 players allowed
|
||||
party_members: List[PartyMember] = field(default_factory=list)
|
||||
|
||||
# Time limits
|
||||
created_at: str = "" # ISO timestamp
|
||||
started_at: Optional[str] = None # When session started
|
||||
expires_at: Optional[str] = None # 2 hours after started_at
|
||||
time_remaining_seconds: int = 7200 # 2 hours = 7200 seconds
|
||||
|
||||
# Campaign
|
||||
campaign: MultiplayerCampaign = None # AI-generated campaign
|
||||
current_encounter_index: int = 0 # Progress through campaign
|
||||
|
||||
# Combat state
|
||||
combat_encounter: Optional[CombatEncounter] = None
|
||||
turn_order: List[str] = field(default_factory=list) # Character IDs
|
||||
current_turn_index: int = 0
|
||||
|
||||
# Conversation/narration
|
||||
conversation_history: List[ConversationEntry] = field(default_factory=list)
|
||||
|
||||
# Tier requirements
|
||||
tier_required: str = "premium" # "premium" or "elite"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if session has exceeded time limit."""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return datetime.utcnow() > datetime.fromisoformat(self.expires_at)
|
||||
|
||||
def get_time_remaining(self) -> int:
|
||||
"""Get remaining time in seconds."""
|
||||
if not self.expires_at:
|
||||
return 7200 # Default 2 hours
|
||||
remaining = datetime.fromisoformat(self.expires_at) - datetime.utcnow()
|
||||
return max(0, int(remaining.total_seconds()))
|
||||
|
||||
def can_join(self, user: UserData) -> bool:
|
||||
"""Check if user can join this session."""
|
||||
# Check tier requirement
|
||||
if user.tier not in ["premium", "elite"]:
|
||||
return False
|
||||
|
||||
# Check party size limit
|
||||
if len(self.party_members) >= self.max_players:
|
||||
return False
|
||||
|
||||
# Check session status
|
||||
if self.status not in ["lobby", "active"]:
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### PartyMember
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PartyMember:
|
||||
"""Represents a player in a multiplayer session."""
|
||||
|
||||
user_id: str # User account ID
|
||||
username: str # Display name
|
||||
character_id: str # Character being used
|
||||
character_snapshot: Character # Snapshot at session start (immutable)
|
||||
is_host: bool = False # Is this player the host?
|
||||
is_ready: bool = False # Ready to start (lobby only)
|
||||
is_connected: bool = True # Currently connected via Realtime
|
||||
joined_at: str = "" # ISO timestamp
|
||||
```
|
||||
|
||||
### MultiplayerCampaign
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MultiplayerCampaign:
|
||||
"""AI-generated short campaign for multiplayer session."""
|
||||
|
||||
campaign_id: str
|
||||
title: str # "The Goblin Raid", "Dragon's Hoard"
|
||||
description: str # Campaign overview
|
||||
difficulty: str # "easy", "medium", "hard", "deadly"
|
||||
estimated_duration_minutes: int # 60-120 minutes
|
||||
encounters: List[CampaignEncounter] = field(default_factory=list)
|
||||
rewards: CampaignRewards = None
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""Check if all encounters are completed."""
|
||||
return all(enc.completed for enc in self.encounters)
|
||||
```
|
||||
|
||||
### CampaignEncounter
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CampaignEncounter:
|
||||
"""A single encounter within a multiplayer campaign."""
|
||||
|
||||
encounter_id: str
|
||||
encounter_type: str # "combat", "puzzle", "boss"
|
||||
title: str # "Goblin Ambush", "The Dragon Awakens"
|
||||
narrative_intro: str # AI-generated intro text
|
||||
narrative_completion: str # AI-generated completion text
|
||||
|
||||
# Combat details (if encounter_type == "combat")
|
||||
enemies: List[Dict] = field(default_factory=list) # Enemy definitions
|
||||
combat_modifiers: Dict = field(default_factory=dict) # Terrain, weather, etc.
|
||||
|
||||
completed: bool = False
|
||||
completion_time: Optional[str] = None
|
||||
```
|
||||
|
||||
### CampaignRewards
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CampaignRewards:
|
||||
"""Rewards for completing the multiplayer campaign."""
|
||||
|
||||
gold_per_player: int = 0
|
||||
experience_per_player: int = 0
|
||||
shared_items: List[str] = field(default_factory=list) # Items to distribute
|
||||
completion_bonus: int = 0 # Bonus for finishing under time limit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Invite System
|
||||
|
||||
### Invite Link Generation
|
||||
|
||||
When a Premium/Elite player creates a multiplayer session:
|
||||
|
||||
1. **Generate unique invite code**: 8-character alphanumeric (e.g., `A7K9X2M4`)
|
||||
2. **Create shareable link**: `https://codeofconquest.com/join/{invite_code}`
|
||||
3. **Set link expiration**: Valid for 24 hours or until session starts
|
||||
4. **Store invite metadata**: Host info, tier requirement, party size
|
||||
|
||||
**Endpoint:** `POST /api/v1/sessions/multiplayer/create`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"max_players": 4,
|
||||
"difficulty": "medium",
|
||||
"tier_required": "premium"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"session_id": "mp_xyz789",
|
||||
"invite_code": "A7K9X2M4",
|
||||
"invite_link": "https://codeofconquest.com/join/A7K9X2M4",
|
||||
"expires_at": "2025-11-17T12:00:00Z",
|
||||
"max_players": 4,
|
||||
"status": "lobby"
|
||||
}
|
||||
```
|
||||
|
||||
### Joining via Invite Link
|
||||
|
||||
Players click the invite link and are prompted to select a character:
|
||||
|
||||
**Endpoint:** `GET /api/v1/sessions/multiplayer/join/{invite_code}`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"session_id": "mp_xyz789",
|
||||
"host_username": "PlayerOne",
|
||||
"max_players": 4,
|
||||
"current_players": 2,
|
||||
"difficulty": "medium",
|
||||
"tier_required": "premium",
|
||||
"can_join": true,
|
||||
"user_characters": [
|
||||
{
|
||||
"character_id": "char_abc123",
|
||||
"name": "Thorin",
|
||||
"class": "Vanguard",
|
||||
"level": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Join Endpoint:** `POST /api/v1/sessions/multiplayer/join/{invite_code}`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"character_id": "char_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "mp_xyz789",
|
||||
"party_member_id": "pm_def456",
|
||||
"lobby_status": {
|
||||
"players_joined": 3,
|
||||
"max_players": 4,
|
||||
"all_ready": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Time Limit
|
||||
|
||||
### 2-Hour Duration
|
||||
|
||||
All multiplayer sessions have a hard 2-hour time limit:
|
||||
|
||||
- **Timer starts**: When host clicks "Start Session" (all players ready)
|
||||
- **Expiration**: Session automatically ends, rewards distributed
|
||||
- **Warnings**: Backend sends notifications at 10min, 5min, 1min remaining
|
||||
|
||||
### Session Expiration Handling
|
||||
|
||||
```python
|
||||
def check_session_expiration(session: MultiplayerSession) -> bool:
|
||||
"""Check if session has expired and handle cleanup."""
|
||||
|
||||
if not session.is_expired():
|
||||
return False
|
||||
|
||||
# Session has expired
|
||||
logger.info(f"Session {session.session_id} expired after 2 hours")
|
||||
|
||||
# If in combat, end combat (players flee)
|
||||
if session.combat_encounter:
|
||||
end_combat(session, status="fled")
|
||||
|
||||
# Calculate partial rewards (progress-based)
|
||||
partial_rewards = calculate_partial_rewards(session)
|
||||
distribute_rewards(session, partial_rewards)
|
||||
|
||||
# Mark session as expired
|
||||
session.status = "expired"
|
||||
save_session(session)
|
||||
|
||||
# Notify all players
|
||||
notify_all_players(session, "Session time limit reached. Rewards distributed.")
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### Completion Before Time Limit
|
||||
|
||||
If the party completes the campaign before 2 hours:
|
||||
|
||||
- **Completion bonus**: Extra gold/XP for finishing quickly
|
||||
- **Session ends**: No need to wait for timer
|
||||
- **Full rewards**: All campaign rewards distributed
|
||||
|
||||
---
|
||||
|
||||
## AI Campaign Generation
|
||||
|
||||
### Campaign Generation at Session Start
|
||||
|
||||
When the host starts the session (all players ready), the AI generates a custom campaign:
|
||||
|
||||
**Campaign Generation Function:**
|
||||
```python
|
||||
def generate_multiplayer_campaign(
|
||||
party_members: List[PartyMember],
|
||||
difficulty: str,
|
||||
duration_minutes: int = 120
|
||||
) -> MultiplayerCampaign:
|
||||
"""Use AI to generate a custom campaign for the party."""
|
||||
|
||||
# Build context
|
||||
context = {
|
||||
"party_size": len(party_members),
|
||||
"party_composition": [
|
||||
{
|
||||
"name": pm.character_snapshot.name,
|
||||
"class": pm.character_snapshot.player_class.name,
|
||||
"level": pm.character_snapshot.level
|
||||
}
|
||||
for pm in party_members
|
||||
],
|
||||
"average_level": sum(pm.character_snapshot.level for pm in party_members) / len(party_members),
|
||||
"difficulty": difficulty,
|
||||
"duration_minutes": duration_minutes
|
||||
}
|
||||
|
||||
# AI prompt
|
||||
prompt = f"""
|
||||
Generate a {difficulty} difficulty D&D-style campaign for a party of {context['party_size']} adventurers.
|
||||
Average party level: {context['average_level']:.1f}
|
||||
Duration: {duration_minutes} minutes
|
||||
Party composition: {context['party_composition']}
|
||||
|
||||
Create a cohesive short campaign with:
|
||||
- Engaging title and description
|
||||
- 3-5 combat encounters (scaled to party level and difficulty)
|
||||
- Narrative connecting the encounters
|
||||
- Appropriate rewards (gold, XP, magic items)
|
||||
- A climactic final boss encounter
|
||||
|
||||
Return JSON format:
|
||||
{{
|
||||
"title": "Campaign title",
|
||||
"description": "Campaign overview",
|
||||
"encounters": [
|
||||
{{
|
||||
"title": "Encounter name",
|
||||
"narrative_intro": "Intro text",
|
||||
"enemies": [/* enemy definitions */],
|
||||
"narrative_completion": "Completion text"
|
||||
}}
|
||||
],
|
||||
"rewards": {{
|
||||
"gold_per_player": 500,
|
||||
"experience_per_player": 1000,
|
||||
"shared_items": ["magic_sword", "healing_potion"]
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
ai_response = call_ai_api(prompt, model="claude-sonnet")
|
||||
campaign_data = parse_json_response(ai_response)
|
||||
|
||||
return MultiplayerCampaign.from_dict(campaign_data)
|
||||
```
|
||||
|
||||
### Example Generated Campaign
|
||||
|
||||
**Title:** "The Goblin Raid on Millstone Village"
|
||||
|
||||
**Description:** A band of goblins has been terrorizing the nearby village of Millstone. The village elder has hired your party to track down the goblin war band and put an end to their raids.
|
||||
|
||||
**Encounters:**
|
||||
1. **Goblin Scouts** - Encounter 2 goblin scouts on the road
|
||||
2. **Ambush in the Woods** - 5 goblins ambush the party
|
||||
3. **The Goblin Camp** - Assault the main goblin camp (8 goblins + 1 hobgoblin)
|
||||
4. **The Goblin Chieftain** - Final boss fight (Goblin Chieftain + 2 elite guards)
|
||||
|
||||
**Rewards:**
|
||||
- 300 gold per player
|
||||
- 800 XP per player
|
||||
- Shared loot: Goblin Warblade, Ring of Minor Protection, 3x Healing Potions
|
||||
|
||||
---
|
||||
|
||||
## Turn Management
|
||||
|
||||
### Turn-Based Cooperative Combat
|
||||
|
||||
In multiplayer combat, all party members take turns along with enemies:
|
||||
|
||||
#### Initialize Multiplayer Combat
|
||||
|
||||
```python
|
||||
def initialize_multiplayer_combat(
|
||||
session: MultiplayerSession,
|
||||
encounter: CampaignEncounter
|
||||
) -> None:
|
||||
"""Start combat for multiplayer session."""
|
||||
|
||||
# Create combatants for all party members
|
||||
party_combatants = []
|
||||
for member in session.party_members:
|
||||
combatant = create_combatant_from_character(member.character_snapshot)
|
||||
combatant.player_id = member.user_id # Track which player controls this
|
||||
party_combatants.append(combatant)
|
||||
|
||||
# Create enemy combatants
|
||||
enemy_combatants = []
|
||||
for enemy_def in encounter.enemies:
|
||||
enemy = create_enemy_combatant(enemy_def)
|
||||
enemy_combatants.append(enemy)
|
||||
|
||||
# Create combat encounter
|
||||
combat = CombatEncounter(
|
||||
encounter_id=f"combat_{session.session_id}_{encounter.encounter_id}",
|
||||
combatants=party_combatants + enemy_combatants,
|
||||
turn_order=[],
|
||||
current_turn_index=0,
|
||||
round_number=1,
|
||||
status="active"
|
||||
)
|
||||
|
||||
# Roll initiative for all combatants
|
||||
combat.initialize_combat()
|
||||
|
||||
# Set combat encounter on session
|
||||
session.combat_encounter = combat
|
||||
save_session(session)
|
||||
|
||||
# Notify all players combat has started
|
||||
notify_all_players(session, "combat_started", {
|
||||
"encounter_title": encounter.title,
|
||||
"turn_order": combat.turn_order
|
||||
})
|
||||
```
|
||||
|
||||
#### Validate Combat Actions
|
||||
|
||||
Only the player whose character's turn it is can take actions:
|
||||
|
||||
**Endpoint:** `POST /api/v1/sessions/multiplayer/{session_id}/combat/action`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"action_type": "attack",
|
||||
"ability_id": "basic_attack",
|
||||
"target_id": "enemy_goblin_1"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```python
|
||||
def validate_combat_action(
|
||||
session: MultiplayerSession,
|
||||
user_id: str,
|
||||
action: CombatAction
|
||||
) -> bool:
|
||||
"""Ensure it's this player's turn."""
|
||||
|
||||
combat = session.combat_encounter
|
||||
current_combatant = combat.get_current_combatant()
|
||||
|
||||
# Check if current combatant belongs to this user
|
||||
if hasattr(current_combatant, 'player_id'):
|
||||
if current_combatant.player_id != user_id:
|
||||
raise NotYourTurnError("It's not your turn")
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Realtime Synchronization
|
||||
|
||||
### Backend Event Broadcasting
|
||||
|
||||
Backend broadcasts events to all players via Appwrite Realtime:
|
||||
|
||||
**Events Broadcast to All Players:**
|
||||
- Player joined lobby
|
||||
- Player ready status changed
|
||||
- Session started
|
||||
- Combat started
|
||||
- Turn advanced
|
||||
- Action taken (attack, spell, item use)
|
||||
- Damage dealt
|
||||
- Character defeated
|
||||
- Combat ended
|
||||
- Encounter completed
|
||||
- Session time warnings (10min, 5min, 1min)
|
||||
- Session expired
|
||||
|
||||
### Player Disconnection Handling
|
||||
|
||||
If a player disconnects during active session:
|
||||
|
||||
```python
|
||||
def handle_player_disconnect(session: MultiplayerSession, user_id: str) -> None:
|
||||
"""Handle player disconnection gracefully."""
|
||||
|
||||
# Mark player as disconnected
|
||||
for member in session.party_members:
|
||||
if member.user_id == user_id:
|
||||
member.is_connected = False
|
||||
break
|
||||
|
||||
# If in combat, set their character to auto-defend
|
||||
if session.combat_encounter:
|
||||
set_character_auto_mode(session.combat_encounter, user_id, mode="defend")
|
||||
|
||||
# Notify other players
|
||||
notify_other_players(session, user_id, "player_disconnected", {
|
||||
"username": member.username,
|
||||
"message": f"{member.username} has disconnected"
|
||||
})
|
||||
|
||||
# If host disconnects, promote new host
|
||||
if member.is_host:
|
||||
promote_new_host(session)
|
||||
```
|
||||
|
||||
**Auto-Defend Mode:**
|
||||
When a player is disconnected, their character automatically:
|
||||
- Takes "Defend" action on their turn (reduces incoming damage)
|
||||
- Skips any decision-making
|
||||
- Continues until player reconnects or session ends
|
||||
|
||||
---
|
||||
|
||||
## Rewards and Character Updates
|
||||
|
||||
### Reward Distribution
|
||||
|
||||
At session end (completion or expiration), rewards are distributed:
|
||||
|
||||
```python
|
||||
def distribute_rewards(session: MultiplayerSession, rewards: CampaignRewards) -> None:
|
||||
"""Distribute rewards to all party members."""
|
||||
|
||||
for member in session.party_members:
|
||||
character = get_character(member.character_id)
|
||||
|
||||
# Grant gold
|
||||
character.gold += rewards.gold_per_player
|
||||
|
||||
# Grant XP (check for level up)
|
||||
character.experience += rewards.experience_per_player
|
||||
leveled_up = check_level_up(character)
|
||||
|
||||
# Grant shared items (distribute evenly)
|
||||
distributed_items = distribute_shared_items(rewards.shared_items, len(session.party_members))
|
||||
for item_id in distributed_items:
|
||||
item = load_item(item_id)
|
||||
character.inventory.append(item)
|
||||
|
||||
# Save character updates
|
||||
update_character(character)
|
||||
|
||||
# Notify player
|
||||
notify_player(member.user_id, "rewards_received", {
|
||||
"gold": rewards.gold_per_player,
|
||||
"experience": rewards.experience_per_player,
|
||||
"items": distributed_items,
|
||||
"leveled_up": leveled_up
|
||||
})
|
||||
```
|
||||
|
||||
### Character Snapshot vs Live Character
|
||||
|
||||
**Important Design Decision:**
|
||||
|
||||
When a player joins a multiplayer session, a **snapshot** of their character is taken:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PartyMember:
|
||||
character_id: str # Original character ID
|
||||
character_snapshot: Character # Immutable copy at session start
|
||||
```
|
||||
|
||||
**Why?**
|
||||
- Multiplayer sessions don't affect character location/state in solo campaigns
|
||||
- Character in solo game can continue progressing independently
|
||||
- Only rewards (gold, XP, items) are transferred back at session end
|
||||
|
||||
**Example:**
|
||||
- Player has "Thorin" at level 5 in Thornfield Plains
|
||||
- Joins multiplayer session (snapshot taken)
|
||||
- Multiplayer session takes place in different location
|
||||
- After 2 hours, session ends
|
||||
- Thorin gains 800 XP, 300 gold, and levels up to 6
|
||||
- **In solo campaign**: Thorin is still in Thornfield Plains, now level 6, with new gold/items
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Session Management
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/sessions/multiplayer/create` | POST | Create new multiplayer session (Premium/Elite only) |
|
||||
| `/api/v1/sessions/multiplayer/join/{invite_code}` | GET | Get session info by invite code |
|
||||
| `/api/v1/sessions/multiplayer/join/{invite_code}` | POST | Join session with character |
|
||||
| `/api/v1/sessions/multiplayer/{session_id}` | GET | Get current session state |
|
||||
| `/api/v1/sessions/multiplayer/{session_id}/ready` | POST | Toggle ready status (lobby) |
|
||||
| `/api/v1/sessions/multiplayer/{session_id}/start` | POST | Start session (host only, all ready) |
|
||||
| `/api/v1/sessions/multiplayer/{session_id}/leave` | POST | Leave session |
|
||||
| `/api/v1/sessions/multiplayer/{session_id}/end` | POST | End session (host only) |
|
||||
|
||||
### Combat Actions
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/sessions/multiplayer/{session_id}/combat/action` | POST | Take combat action (attack, spell, item, defend) |
|
||||
| `/api/v1/sessions/multiplayer/{session_id}/combat/state` | GET | Get current combat state |
|
||||
|
||||
### Campaign Progress
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/sessions/multiplayer/{session_id}/campaign` | GET | Get campaign overview and progress |
|
||||
| `/api/v1/sessions/multiplayer/{session_id}/rewards` | GET | Get final rewards (after completion) |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Week 12: Core Multiplayer Infrastructure (Days 1-7)
|
||||
|
||||
| Task | Priority | Status | Notes |
|
||||
|------|----------|--------|-------|
|
||||
| Create MultiplayerSession dataclass | High | ⬜ | Extends GameSession with time limits, invite codes |
|
||||
| Create PartyMember dataclass | High | ⬜ | Player info, character snapshot |
|
||||
| Create MultiplayerCampaign models | High | ⬜ | Campaign, CampaignEncounter, CampaignRewards |
|
||||
| Implement invite code generation | High | ⬜ | 8-char alphanumeric, unique, 24hr expiration |
|
||||
| Implement session creation API | High | ⬜ | POST /sessions/multiplayer/create (Premium/Elite only) |
|
||||
| Implement join via invite API | High | ⬜ | GET/POST /join/{invite_code} |
|
||||
| Implement lobby system | High | ⬜ | Ready status, player list, host controls |
|
||||
| Implement 2-hour timer logic | High | ⬜ | Session expiration, warnings, auto-end |
|
||||
| Set up Appwrite Realtime | High | ⬜ | WebSocket subscriptions for live updates |
|
||||
| Write unit tests | Medium | ⬜ | Invite generation, join validation, timer logic |
|
||||
|
||||
### Week 13: Campaign Generation & Combat (Days 8-14)
|
||||
|
||||
| Task | Priority | Status | Notes |
|
||||
|------|----------|--------|-------|
|
||||
| Implement AI campaign generator | High | ⬜ | Generate 3-5 encounters based on party composition |
|
||||
| Create campaign templates | Medium | ⬜ | Pre-built campaign structures for AI to fill |
|
||||
| Implement turn management | High | ⬜ | Initiative, turn order, action validation |
|
||||
| Implement multiplayer combat flow | High | ⬜ | Reuse Phase 5 combat, add multi-player support |
|
||||
| Implement disconnect handling | High | ⬜ | Auto-defend mode, host promotion |
|
||||
| Implement reward distribution | High | ⬜ | Calculate and grant rewards at session end |
|
||||
| Write integration tests | High | ⬜ | Full session flow: create → join → play → complete |
|
||||
| Test session expiration | Medium | ⬜ | Force expiration, verify cleanup |
|
||||
|
||||
---
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
### AI Campaign Generation Cost
|
||||
|
||||
| Tier | Model | Campaign Gen Cost | Per Session |
|
||||
|------|-------|-------------------|-------------|
|
||||
| Premium | Claude Sonnet | ~3000 tokens | ~$0.09 |
|
||||
| Elite | Claude Opus | ~3000 tokens | ~$0.45 |
|
||||
|
||||
**Mitigation:**
|
||||
- Campaign generation happens once per session
|
||||
- Can cache campaign templates
|
||||
- Cost is acceptable for paid-tier feature
|
||||
|
||||
### Realtime Connection Cost
|
||||
|
||||
Appwrite Realtime connections are included in Appwrite Cloud pricing. No additional cost per connection.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[STORY_PROGRESSION.md](STORY_PROGRESSION.md)** - Solo story gameplay (comparison)
|
||||
- **[GAME_SYSTEMS.md](GAME_SYSTEMS.md)** - Combat system (reused in multiplayer)
|
||||
- **[DATA_MODELS.md](DATA_MODELS.md)** - GameSession, CombatEncounter models
|
||||
- **[API_REFERENCE.md](API_REFERENCE.md)** - Full API endpoint documentation
|
||||
- **[/public_web/docs/MULTIPLAYER.md](../../public_web/docs/MULTIPLAYER.md)** - Web frontend UI implementation
|
||||
- **[/godot_client/docs/MULTIPLAYER.md](../../godot_client/docs/MULTIPLAYER.md)** - Godot client implementation
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 2.0 (Microservices Split)
|
||||
**Created:** November 16, 2025
|
||||
**Last Updated:** November 18, 2025
|
||||
940
api/docs/PHASE4_IMPLEMENTATION.md
Normal file
940
api/docs/PHASE4_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,940 @@
|
||||
# Phase 4: AI Integration + Story Progression + Quest System
|
||||
## Implementation Plan
|
||||
|
||||
> **Note:** This document contains detailed implementation tasks for developers building the API backend.
|
||||
> For high-level project roadmap and progress tracking, see [/docs/ROADMAP.md](../../docs/ROADMAP.md).
|
||||
|
||||
**Document Version:** 1.2
|
||||
**Created:** November 16, 2025
|
||||
**Last Updated:** November 23, 2025
|
||||
**Status:** In Progress
|
||||
**Duration:** 3 weeks (21 days)
|
||||
**Total Tasks:** 45 tasks
|
||||
**Completed Tasks:** 22/45 (49%)
|
||||
|
||||
---
|
||||
|
||||
### Next Tasks
|
||||
- Task 8.29: Create story gameplay template with HTMX
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Phase 4 delivers the core single-player gameplay experience for Code of Conquest. This phase integrates AI narrative generation via Replicate (Llama-3 and Claude models), implements turn-based story progression with button-based actions, and creates a context-aware quest system.
|
||||
|
||||
> **Architecture Note:** All AI models (Llama-3, Claude Haiku/Sonnet/Opus) are accessed through the Replicate API for unified billing and management.
|
||||
|
||||
**Key Deliverables:**
|
||||
- AI narrative generation with tier-based model selection
|
||||
- Turn-based story progression system
|
||||
- YAML-driven quest system with context-aware offering
|
||||
- Cost tracking and usage limits
|
||||
- Complete solo gameplay loop
|
||||
|
||||
**Development Approach:**
|
||||
- Tasks are moderately granular (4 hours each)
|
||||
- Testing bundled into implementation tasks
|
||||
- Verification checkpoints after major features
|
||||
- YAML data created inline with features
|
||||
|
||||
---
|
||||
|
||||
## Week 8: Story Progression System (Days 8-14)
|
||||
|
||||
**Goal:** Implement turn-based story progression with button-based actions
|
||||
|
||||
### Task Group 8: Story UI & Integration (Tasks 29-31)
|
||||
|
||||
#### Task 8.29: Create story gameplay template with HTMX
|
||||
**Duration:** 5 hours
|
||||
**File:** `templates/game/story.html`
|
||||
|
||||
**Implementation:**
|
||||
- Create main story gameplay page layout:
|
||||
- Header: Character info, turn count, location
|
||||
- Left sidebar: Quest tracker (placeholder for Week 9)
|
||||
- Main area: Latest DM response
|
||||
- Action panel: Available action buttons
|
||||
- Footer: Custom input (if Premium/Elite)
|
||||
- Right sidebar: Conversation history (collapsible)
|
||||
- Add HTMX for dynamic updates:
|
||||
- Action buttons trigger `hx-post="/api/v1/sessions/{id}/action"`
|
||||
- Poll job status with `hx-trigger="every 2s"`
|
||||
- Update content when job completes
|
||||
- Add loading spinner during AI processing
|
||||
- Style with dark fantasy theme (match existing CSS)
|
||||
- Add responsive design (mobile-friendly)
|
||||
- Write Flask route to render template
|
||||
- Test UI rendering
|
||||
|
||||
**Dependencies:** Tasks 8.25-8.28 (API endpoints)
|
||||
**Deliverable:** Story gameplay UI template
|
||||
|
||||
**Key HTMX Patterns:**
|
||||
```html
|
||||
<!-- Action button -->
|
||||
<button
|
||||
hx-post="/api/v1/sessions/{{ session_id }}/action"
|
||||
hx-vals='{"action_type": "button", "prompt_id": "ask_locals"}'
|
||||
hx-target="#dm-response"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#loading">
|
||||
Ask locals for information
|
||||
</button>
|
||||
|
||||
<!-- Job status polling -->
|
||||
<div
|
||||
hx-get="/api/v1/jobs/{{ job_id }}/status"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="outerHTML">
|
||||
Processing...
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 8.30: Build action button UI with tier filtering
|
||||
**Duration:** 4 hours
|
||||
**File:** `templates/game/story.html` (extend), `app/routes/game.py`
|
||||
|
||||
**Implementation:**
|
||||
- Create Jinja2 macro for rendering action buttons
|
||||
- Filter actions based on user tier (passed from backend)
|
||||
- Show locked actions with upgrade prompt for higher tiers
|
||||
- Add tooltips with action descriptions
|
||||
- Implement custom text input area:
|
||||
- Only visible for Premium/Elite
|
||||
- Character counter (250 for Premium, 500 for Elite)
|
||||
- Submit button with validation
|
||||
- Add HTMX for seamless submission
|
||||
- Style buttons with RPG aesthetic (icons optional)
|
||||
- Disable buttons during AI processing
|
||||
- Write Flask route to provide available actions
|
||||
|
||||
**Dependencies:** Task 8.29 (Story template)
|
||||
**Deliverable:** Dynamic action button system
|
||||
|
||||
**Jinja2 Macro:**
|
||||
```jinja
|
||||
{% macro render_action(action, user_tier, locked=False) %}
|
||||
<button
|
||||
class="action-btn {% if locked %}locked{% endif %}"
|
||||
{% if not locked %}
|
||||
hx-post="/api/v1/sessions/{{ session_id }}/action"
|
||||
hx-vals='{"action_type": "button", "prompt_id": "{{ action.prompt_id }}"}'
|
||||
{% else %}
|
||||
disabled
|
||||
{% endif %}
|
||||
title="{{ action.description }}">
|
||||
{{ action.display_text }}
|
||||
{% if locked %}<span class="lock-icon">🔒</span>{% endif %}
|
||||
</button>
|
||||
{% endmacro %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 8.31: ✅ CHECKPOINT - Full story turn integration test
|
||||
**Duration:** 4 hours
|
||||
|
||||
**Verification Steps:**
|
||||
1. Create a new session via UI
|
||||
2. Click an action button
|
||||
3. Verify loading state appears
|
||||
4. Wait for AI response
|
||||
5. Verify DM response displayed
|
||||
6. Check conversation history updated
|
||||
7. Verify turn number incremented
|
||||
8. Test with different action buttons
|
||||
9. Test custom text input (Premium tier)
|
||||
10. Verify tier restrictions enforced (Free can't use Premium actions)
|
||||
11. Test rate limiting
|
||||
12. Verify Realtime updates work
|
||||
|
||||
**Success Criteria:**
|
||||
- Full story turn loop works end-to-end
|
||||
- UI updates smoothly with HTMX
|
||||
- AI responses are coherent and relevant
|
||||
- Tier filtering works correctly
|
||||
- Rate limits enforced
|
||||
- No errors in browser console or server logs
|
||||
|
||||
---
|
||||
|
||||
## Week 9: Quest System (Days 15-21)
|
||||
|
||||
**Goal:** Implement YAML-driven quest system with context-aware offering
|
||||
|
||||
### Task Group 9: Quest Data Models (Tasks 32-34)
|
||||
|
||||
#### Task 9.32: Create Quest dataclasses
|
||||
**Duration:** 5 hours
|
||||
**File:** `app/models/quest.py`
|
||||
|
||||
**Implementation:**
|
||||
- Create `QuestObjective` dataclass:
|
||||
- `objective_id`, `description`, `objective_type` (enum)
|
||||
- `required_progress`, `current_progress`, `completed`
|
||||
- Create `QuestReward` dataclass:
|
||||
- `gold`, `experience`, `items` (List[Item])
|
||||
- `reputation` (optional, for future use)
|
||||
- Create `Quest` dataclass:
|
||||
- `quest_id`, `name`, `description`, `quest_giver`
|
||||
- `difficulty` (enum: EASY, MEDIUM, HARD, EPIC)
|
||||
- `objectives` (List[QuestObjective])
|
||||
- `rewards` (QuestReward)
|
||||
- `offering_triggers` (QuestTriggers)
|
||||
- `narrative_hooks` (List[str])
|
||||
- `status` (enum: AVAILABLE, ACTIVE, COMPLETED, FAILED)
|
||||
- Implement methods:
|
||||
- `is_complete()` - Check if all objectives done
|
||||
- `get_next_objective()` - Get first incomplete objective
|
||||
- `update_progress(objective_id, amount)` - Increment progress
|
||||
- Add to_dict() / from_dict() serialization
|
||||
- Write unit tests for all methods
|
||||
|
||||
**Dependencies:** None
|
||||
**Deliverable:** Quest data models
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
quest = Quest(
|
||||
quest_id="quest_goblin_cave",
|
||||
name="Clear the Goblin Cave",
|
||||
description="A nearby cave is infested with goblins...",
|
||||
quest_giver="Village Elder",
|
||||
difficulty=QuestDifficulty.EASY,
|
||||
objectives=[
|
||||
QuestObjective(
|
||||
objective_id="kill_goblins",
|
||||
description="Defeat 5 goblins",
|
||||
objective_type=ObjectiveType.KILL,
|
||||
required_progress=5,
|
||||
current_progress=0
|
||||
)
|
||||
],
|
||||
rewards=QuestReward(gold=50, experience=100, items=[])
|
||||
)
|
||||
|
||||
quest.update_progress("kill_goblins", 1)
|
||||
quest.is_complete() # False (4 more goblins needed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.33: Create QuestTriggers with offering logic
|
||||
**Duration:** 4 hours
|
||||
**File:** `app/models/quest.py` (extend)
|
||||
|
||||
**Implementation:**
|
||||
- Create `QuestTriggers` dataclass:
|
||||
- `location_types` (List[LocationType]) - Where quest can be offered
|
||||
- `specific_locations` (List[str]) - Optional specific location names
|
||||
- `min_character_level`, `max_character_level` (int)
|
||||
- `required_quests_completed` (List[str]) - Quest prerequisites
|
||||
- `probability_weights` (Dict[LocationType, float]) - Offer chance by location
|
||||
- Implement methods:
|
||||
- `can_offer(character_level, completed_quests)` - Check eligibility
|
||||
- `get_offer_probability(location_type)` - Get chance for location
|
||||
- Add validation (probabilities 0.0-1.0)
|
||||
- Write unit tests for offering logic
|
||||
- Document trigger system in docstrings
|
||||
|
||||
**Dependencies:** Task 9.32 (Quest model)
|
||||
**Deliverable:** Quest offering trigger system
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
triggers = QuestTriggers(
|
||||
location_types=[LocationType.TOWN, LocationType.TAVERN],
|
||||
min_character_level=1,
|
||||
max_character_level=5,
|
||||
probability_weights={
|
||||
LocationType.TOWN: 0.30,
|
||||
LocationType.TAVERN: 0.35
|
||||
}
|
||||
)
|
||||
|
||||
triggers.can_offer(character_level=3, completed_quests=[]) # True
|
||||
triggers.get_offer_probability(LocationType.TAVERN) # 0.35 (35% chance)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.34: ✅ CHECKPOINT - Verify quest model serialization
|
||||
**Duration:** 2 hours
|
||||
|
||||
**Verification Steps:**
|
||||
1. Create sample quest with all fields
|
||||
2. Convert to dict with to_dict()
|
||||
3. Serialize to JSON
|
||||
4. Deserialize from JSON
|
||||
5. Recreate quest with from_dict()
|
||||
6. Verify all fields match original
|
||||
7. Test quest methods (is_complete, update_progress)
|
||||
8. Test trigger methods (can_offer, get_offer_probability)
|
||||
9. Test edge cases (invalid progress, level requirements)
|
||||
|
||||
**Success Criteria:**
|
||||
- Quest serialization round-trips correctly
|
||||
- All methods work as expected
|
||||
- Offering logic accurate
|
||||
- No data loss during serialization
|
||||
|
||||
---
|
||||
|
||||
### Task Group 10: Quest Content & Loading (Tasks 35-38)
|
||||
|
||||
#### Task 9.35: Create quest YAML schema
|
||||
**Duration:** 3 hours
|
||||
**File:** `app/data/quests/schema.yaml` (documentation), update `docs/QUEST_SYSTEM.md`
|
||||
|
||||
**Implementation:**
|
||||
- Document YAML structure for quests
|
||||
- Define all required and optional fields
|
||||
- Provide examples for each objective type
|
||||
- Document narrative_hooks usage
|
||||
- Create template quest file
|
||||
- Add validation rules
|
||||
- Update QUEST_SYSTEM.md with schema details
|
||||
|
||||
**Dependencies:** Task 9.32 (Quest models)
|
||||
**Deliverable:** Quest YAML schema documentation
|
||||
|
||||
**Schema Example:**
|
||||
```yaml
|
||||
quest_id: quest_goblin_cave
|
||||
name: Clear the Goblin Cave
|
||||
description: |
|
||||
A nearby cave has been overrun by goblins who are raiding nearby farms.
|
||||
The village elder asks you to clear them out.
|
||||
quest_giver: Village Elder
|
||||
difficulty: EASY
|
||||
|
||||
objectives:
|
||||
- objective_id: kill_goblins
|
||||
description: Defeat 5 goblins
|
||||
objective_type: KILL
|
||||
required_progress: 5
|
||||
|
||||
rewards:
|
||||
gold: 50
|
||||
experience: 100
|
||||
items: []
|
||||
|
||||
offering_triggers:
|
||||
location_types: [TOWN, TAVERN]
|
||||
min_character_level: 1
|
||||
max_character_level: 5
|
||||
probability_weights:
|
||||
TOWN: 0.30
|
||||
TAVERN: 0.35
|
||||
|
||||
narrative_hooks:
|
||||
- "The village elder looks worried about recent goblin attacks"
|
||||
- "You hear farmers complaining about lost livestock"
|
||||
- "A town guard mentions a cave to the north"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.36: Write 10 example quests
|
||||
**Duration:** 5 hours
|
||||
**Files:** `app/data/quests/easy/*.yaml`, `app/data/quests/medium/*.yaml`, etc.
|
||||
|
||||
**Implementation:**
|
||||
- Create quest files organized by difficulty:
|
||||
- **Easy (4 quests):** Levels 1-3, simple objectives
|
||||
1. Clear Goblin Cave (kill 5 goblins)
|
||||
2. Gather Healing Herbs (collect 10 herbs)
|
||||
3. Deliver Message to Town (travel to location)
|
||||
4. Find Lost Cat (discover location)
|
||||
- **Medium (3 quests):** Levels 3-7, multi-objective
|
||||
5. Investigate Bandit Camp (kill + collect + discover)
|
||||
6. Rescue Kidnapped Villager (travel + interact)
|
||||
7. Ancient Artifact Recovery (discover + collect)
|
||||
- **Hard (2 quests):** Levels 7-10, complex chains
|
||||
8. Stop the Necromancer (multi-step with prerequisites)
|
||||
9. Dragon's Hoard (discover + kill boss + collect)
|
||||
- **Epic (1 quest):** Level 10+, multi-chapter
|
||||
10. The Demon Lord's Return (epic multi-objective chain)
|
||||
- Include rich narrative_hooks for each quest
|
||||
- Vary reward amounts by difficulty
|
||||
- Add location variety
|
||||
- Ensure proper level gating
|
||||
|
||||
**Dependencies:** Task 9.35 (YAML schema)
|
||||
**Deliverable:** 10 complete quest YAML files
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.37: Implement QuestService with YAML loader
|
||||
**Duration:** 5 hours
|
||||
**File:** `app/services/quest_service.py`
|
||||
|
||||
**Implementation:**
|
||||
- Create `QuestService` class
|
||||
- Implement `load_quests_from_yaml(directory)` method
|
||||
- Parse all YAML files in quest directory
|
||||
- Convert to Quest objects
|
||||
- Validate quest structure and fields
|
||||
- Cache loaded quests in memory
|
||||
- Implement methods:
|
||||
- `get_quest_by_id(quest_id)`
|
||||
- `get_eligible_quests(character_level, location_type, completed_quests)`
|
||||
- `filter_by_difficulty(difficulty)`
|
||||
- `get_all_quests()`
|
||||
- Add error handling for malformed YAML
|
||||
- Write unit tests with sample quests
|
||||
- Add logging for quest loading
|
||||
|
||||
**Dependencies:** Task 9.36 (Quest YAML files)
|
||||
**Deliverable:** Quest loading and filtering service
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
service = QuestService()
|
||||
service.load_quests_from_yaml("app/data/quests/")
|
||||
|
||||
# Get eligible quests
|
||||
eligible = service.get_eligible_quests(
|
||||
character_level=3,
|
||||
location_type=LocationType.TAVERN,
|
||||
completed_quests=[]
|
||||
)
|
||||
# Returns: [Quest(...), Quest(...)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.38: ✅ CHECKPOINT - Verify quest loading and validation
|
||||
**Duration:** 2 hours
|
||||
|
||||
**Verification Steps:**
|
||||
1. Load all 10 quests from YAML files
|
||||
2. Verify all quests parsed correctly
|
||||
3. Check quest filtering by level works
|
||||
4. Test filtering by location type
|
||||
5. Verify offering probability calculations
|
||||
6. Test get_eligible_quests() with various inputs
|
||||
7. Verify error handling for invalid YAML
|
||||
8. Check quest caching works
|
||||
|
||||
**Success Criteria:**
|
||||
- All 10 quests load without errors
|
||||
- Filtering logic accurate
|
||||
- Offering probabilities correct
|
||||
- No performance issues loading quests
|
||||
- Error handling graceful
|
||||
|
||||
---
|
||||
|
||||
### Task Group 11: Quest Offering & Management (Tasks 39-42)
|
||||
|
||||
#### Task 9.39: Implement context-aware quest offering logic
|
||||
**Duration:** 5 hours
|
||||
**File:** `app/services/quest_offering_service.py`
|
||||
|
||||
**Implementation:**
|
||||
- Create `QuestOfferingService` class
|
||||
- Implement two-stage offering:
|
||||
1. **Location probability roll:**
|
||||
- Get location type probability from triggers
|
||||
- Roll random 0.0-1.0, check if < probability
|
||||
- If fail, no quest offered
|
||||
2. **Context-aware AI selection:**
|
||||
- Get eligible quests from QuestService
|
||||
- Build AI prompt with narrative_hooks
|
||||
- Ask AI to select most contextually relevant quest
|
||||
- Parse AI response to get selected quest_id
|
||||
- Implement `should_offer_quest(location_type)` method (probability roll)
|
||||
- Implement `select_quest_for_context(eligible_quests, game_context)` method (AI selection)
|
||||
- Implement main `offer_quest(session_id)` method (full flow)
|
||||
- Add validation (max 2 active quests)
|
||||
- Write integration tests with mocked AI
|
||||
- Add logging for quest offerings
|
||||
|
||||
**Dependencies:** Tasks 7.10 (NarrativeGenerator), 9.37 (QuestService)
|
||||
**Deliverable:** Context-aware quest offering system
|
||||
|
||||
**Flow:**
|
||||
```python
|
||||
offering_service = QuestOfferingService()
|
||||
|
||||
# Called after each story turn
|
||||
if offering_service.should_offer_quest(LocationType.TAVERN):
|
||||
eligible = quest_service.get_eligible_quests(...)
|
||||
selected_quest = offering_service.select_quest_for_context(
|
||||
eligible_quests=eligible,
|
||||
game_context={
|
||||
"location": "The Rusty Anchor",
|
||||
"recent_actions": ["talked to locals", "rested"],
|
||||
"active_quests": []
|
||||
}
|
||||
)
|
||||
# Returns: Quest object or None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.40: Integrate quest offering into story turns
|
||||
**Duration:** 4 hours
|
||||
**File:** `app/tasks/ai_tasks.py` (extend generate_dm_response job)
|
||||
|
||||
**Implementation:**
|
||||
- Update `generate_dm_response()` RQ job
|
||||
- After AI narrative generated and before saving:
|
||||
1. Check if quest should be offered (probability roll)
|
||||
2. If yes, get eligible quests
|
||||
3. Call AI to select contextually relevant quest
|
||||
4. Add quest offering to response data
|
||||
5. Store offered quest in session state (pending acceptance)
|
||||
- Add quest offering to conversation entry:
|
||||
```python
|
||||
{
|
||||
"turn": 5,
|
||||
"action": "...",
|
||||
"dm_response": "...",
|
||||
"quest_offered": {
|
||||
"quest_id": "quest_goblin_cave",
|
||||
"quest_name": "Clear the Goblin Cave"
|
||||
}
|
||||
}
|
||||
```
|
||||
- Update API response format to include quest offering
|
||||
- Write integration tests for offering flow
|
||||
- Add logging for quest offerings
|
||||
|
||||
**Dependencies:** Task 9.39 (Quest offering logic)
|
||||
**Deliverable:** Integrated quest offering in story turns
|
||||
|
||||
**Updated Response:**
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"dm_response": "As you chat with the locals...",
|
||||
"quest_offered": {
|
||||
"quest_id": "quest_goblin_cave",
|
||||
"name": "Clear the Goblin Cave",
|
||||
"description": "...",
|
||||
"rewards": {"gold": 50, "experience": 100}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.41: Implement quest accept endpoint
|
||||
**Duration:** 4 hours
|
||||
**File:** `app/routes/quests.py` (new file)
|
||||
|
||||
**Implementation:**
|
||||
- Create `POST /api/v1/quests/accept` endpoint
|
||||
- Validate request body:
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_789",
|
||||
"quest_id": "quest_goblin_cave"
|
||||
}
|
||||
```
|
||||
- Validate quest is currently offered to session
|
||||
- Check max 2 active quests limit
|
||||
- Add quest to session's active_quests
|
||||
- Initialize quest with status ACTIVE
|
||||
- Store quest state in character (or session)
|
||||
- Return accepted quest details
|
||||
- Add @require_auth decorator
|
||||
- Write integration tests
|
||||
- Document in API_REFERENCE.md
|
||||
|
||||
**Dependencies:** Task 9.39 (Quest offering)
|
||||
**Deliverable:** Quest acceptance endpoint
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"quest_id": "quest_goblin_cave",
|
||||
"status": "ACTIVE",
|
||||
"objectives": [
|
||||
{
|
||||
"objective_id": "kill_goblins",
|
||||
"description": "Defeat 5 goblins",
|
||||
"progress": "0/5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.42: Implement quest complete endpoint with rewards
|
||||
**Duration:** 5 hours
|
||||
**File:** `app/routes/quests.py` (extend)
|
||||
|
||||
**Implementation:**
|
||||
- Create `POST /api/v1/quests/complete` endpoint
|
||||
- Validate request body:
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_789",
|
||||
"quest_id": "quest_goblin_cave"
|
||||
}
|
||||
```
|
||||
- Verify quest is active for session
|
||||
- Check all objectives completed
|
||||
- Grant rewards:
|
||||
- Add gold to character
|
||||
- Add experience to character
|
||||
- Add items to inventory
|
||||
- Check for level up
|
||||
- Update quest status to COMPLETED
|
||||
- Remove from active_quests
|
||||
- Add to completed_quests list
|
||||
- Return completion details with level up info
|
||||
- Add @require_auth decorator
|
||||
- Write integration tests
|
||||
- Document in API_REFERENCE.md
|
||||
|
||||
**Dependencies:** Task 9.41 (Quest accept)
|
||||
**Deliverable:** Quest completion and reward system
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"quest_id": "quest_goblin_cave",
|
||||
"status": "COMPLETED",
|
||||
"rewards_granted": {
|
||||
"gold": 50,
|
||||
"experience": 100,
|
||||
"items": []
|
||||
},
|
||||
"level_up": {
|
||||
"leveled_up": true,
|
||||
"new_level": 4,
|
||||
"skill_points_gained": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task Group 12: Quest UI & Final Testing (Tasks 43-45)
|
||||
|
||||
#### Task 9.43: Create quest tracker sidebar UI
|
||||
**Duration:** 4 hours
|
||||
**File:** `templates/game/story.html` (extend left sidebar)
|
||||
|
||||
**Implementation:**
|
||||
- Add quest tracker to left sidebar
|
||||
- Display active quests (max 2)
|
||||
- Show quest name and description
|
||||
- Display objective progress (X/Y format)
|
||||
- Add "View Details" button for each quest
|
||||
- Style with RPG theme
|
||||
- Add HTMX for dynamic updates when objectives progress
|
||||
- Show "No active quests" message when empty
|
||||
- Add quest complete notification (toast/modal)
|
||||
- Test UI rendering
|
||||
|
||||
**Dependencies:** Tasks 9.41-9.42 (Quest endpoints)
|
||||
**Deliverable:** Quest tracker sidebar UI
|
||||
|
||||
**UI Structure:**
|
||||
```html
|
||||
<div class="quest-tracker">
|
||||
<h3>Active Quests ({{ active_quests|length }}/2)</h3>
|
||||
{% for quest in active_quests %}
|
||||
<div class="quest-card">
|
||||
<h4>{{ quest.name }}</h4>
|
||||
<div class="objectives">
|
||||
{% for obj in quest.objectives %}
|
||||
<div class="objective {% if obj.completed %}completed{% endif %}">
|
||||
{{ obj.description }}: {{ obj.current_progress }}/{{ obj.required_progress }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button hx-get="/quests/{{ quest.quest_id }}/details">View Details</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.44: Create quest offering modal UI
|
||||
**Duration:** 4 hours
|
||||
**File:** `templates/game/partials/quest_offer_modal.html`
|
||||
|
||||
**Implementation:**
|
||||
- Create modal component for quest offering
|
||||
- Display when quest offered in DM response
|
||||
- Show quest name, description, quest giver
|
||||
- Display objectives and rewards clearly
|
||||
- Add "Accept Quest" button (HTMX post to /api/v1/quests/accept)
|
||||
- Add "Decline" button (closes modal)
|
||||
- Style modal with RPG theme (parchment background)
|
||||
- Add HTMX to update quest tracker when accepted
|
||||
- Show error if max quests reached (2/2)
|
||||
- Test modal behavior
|
||||
|
||||
**Dependencies:** Task 9.41 (Quest accept endpoint)
|
||||
**Deliverable:** Quest offering modal UI
|
||||
|
||||
**Modal Structure:**
|
||||
```html
|
||||
<div class="modal quest-offer-modal">
|
||||
<div class="modal-content parchment">
|
||||
<h2>{{ quest.quest_giver }} offers you a quest!</h2>
|
||||
<h3>{{ quest.name }}</h3>
|
||||
<p>{{ quest.description }}</p>
|
||||
|
||||
<div class="objectives">
|
||||
<h4>Objectives:</h4>
|
||||
<ul>
|
||||
{% for obj in quest.objectives %}
|
||||
<li>{{ obj.description }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rewards">
|
||||
<h4>Rewards:</h4>
|
||||
<p>Gold: {{ quest.rewards.gold }} | XP: {{ quest.rewards.experience }}</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
hx-post="/api/v1/quests/accept"
|
||||
hx-vals='{"session_id": "{{ session_id }}", "quest_id": "{{ quest.quest_id }}"}'
|
||||
hx-target="#quest-tracker"
|
||||
class="btn-accept">Accept Quest</button>
|
||||
<button class="btn-decline" onclick="closeModal()">Decline</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 9.45: ✅ FINAL CHECKPOINT - Full quest integration test
|
||||
**Duration:** 4 hours
|
||||
|
||||
**Comprehensive Test Flow:**
|
||||
|
||||
**Setup:**
|
||||
1. Create new character (level 1)
|
||||
2. Create solo session
|
||||
3. Verify starting state
|
||||
|
||||
**Quest Offering:**
|
||||
4. Take multiple story actions in a town/tavern
|
||||
5. Wait for quest offering (may take several turns)
|
||||
6. Verify quest modal appears
|
||||
7. Check quest details display correctly
|
||||
8. Accept quest
|
||||
9. Verify quest appears in tracker (1/2 active)
|
||||
|
||||
**Quest Progress:**
|
||||
10. Simulate quest progress (update objective manually via API or through combat)
|
||||
11. Verify tracker updates in real-time
|
||||
12. Complete all objectives
|
||||
13. Verify completion indicator
|
||||
|
||||
**Quest Completion:**
|
||||
14. Call complete quest endpoint
|
||||
15. Verify rewards granted (gold, XP)
|
||||
16. Check for level up if applicable
|
||||
17. Verify quest removed from active list
|
||||
18. Verify quest in completed list
|
||||
|
||||
**Edge Cases:**
|
||||
19. Try accepting 3rd quest (should fail with max 2 message)
|
||||
20. Try completing incomplete quest (should fail)
|
||||
21. Test with ineligible quest (wrong level)
|
||||
22. Verify offering probabilities work (multiple sessions)
|
||||
|
||||
**Success Criteria:**
|
||||
- Full quest lifecycle works end-to-end
|
||||
- Quest offering feels natural in story flow
|
||||
- UI updates smoothly with HTMX
|
||||
- Rewards granted correctly
|
||||
- Level up system works
|
||||
- Max 2 quest limit enforced
|
||||
- Error handling graceful
|
||||
- No bugs in browser console or server logs
|
||||
|
||||
---
|
||||
|
||||
## Deferred Tasks
|
||||
|
||||
### Task 7.15: Set up cost monitoring and alerts
|
||||
**Duration:** 3 hours
|
||||
**Files:** `app/tasks/monitoring_tasks.py`, `app/services/alert_service.py`
|
||||
|
||||
**Implementation:**
|
||||
- Create `calculate_daily_cost()` RQ job (runs daily at midnight)
|
||||
- Aggregate all AI usage from previous day
|
||||
- Calculate total cost by summing estimated costs
|
||||
- Store daily cost in Redis timeseries
|
||||
- Create `AlertService` class
|
||||
- Implement alert triggers:
|
||||
- Daily cost > $50 → Warning email
|
||||
- Daily cost > $100 → Critical email
|
||||
- Monthly projection > $1500 → Warning email
|
||||
- Add email sending via SMTP or service (e.g., SendGrid)
|
||||
- Create admin dashboard endpoint: `GET /admin/costs`
|
||||
- Write tests for cost calculation
|
||||
|
||||
**Dependencies:** Task 7.13 (Usage tracking)
|
||||
**Deliverable:** Automated cost monitoring with alerts
|
||||
|
||||
**Daily Job:**
|
||||
```python
|
||||
# Runs at midnight UTC
|
||||
@job('monitoring_tasks')
|
||||
def calculate_daily_cost():
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
total_cost = sum_all_user_costs(yesterday)
|
||||
|
||||
if total_cost > 100:
|
||||
send_alert(f"CRITICAL: Daily AI cost ${total_cost}")
|
||||
elif total_cost > 50:
|
||||
send_alert(f"WARNING: Daily AI cost ${total_cost}")
|
||||
|
||||
store_cost_metric(yesterday, total_cost)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 Complete!
|
||||
|
||||
**Deliverables Summary:**
|
||||
|
||||
### Week 7: AI Engine Foundation ✅
|
||||
- Redis/RQ infrastructure working
|
||||
- AI clients for Replicate + Anthropic (Haiku/Sonnet/Opus)
|
||||
- Model selection with tier-based routing
|
||||
- Jinja2 prompt templates (4 types)
|
||||
- Narrative generator wrapper
|
||||
- Async AI task jobs with Appwrite integration
|
||||
- Usage tracking and cost monitoring
|
||||
- Daily limits per tier enforced
|
||||
|
||||
### Week 8: Story Progression System ✅
|
||||
- 10 action prompts defined in YAML
|
||||
- ActionPrompt loader with tier/context filtering
|
||||
- Solo GameSession model with state tracking
|
||||
- SessionService for CRUD operations
|
||||
- Conversation history management
|
||||
- 4 API endpoints (create, state, action, history)
|
||||
- Story gameplay UI with HTMX
|
||||
- Dynamic action buttons with tier filtering
|
||||
- Full story turn loop working
|
||||
|
||||
### Week 9: Quest System ✅
|
||||
- Quest data models (Quest, Objective, Reward, Triggers)
|
||||
- 10 example quests in YAML (4 easy, 3 medium, 2 hard, 1 epic)
|
||||
- QuestService with YAML loader
|
||||
- Context-aware quest offering logic
|
||||
- Quest offering integrated into story turns
|
||||
- Quest accept/complete API endpoints
|
||||
- Quest tracker sidebar UI
|
||||
- Quest offering modal UI
|
||||
- Full quest lifecycle tested
|
||||
|
||||
**Next Phase:** Phase 5 - Combat System + Skill Tree UI (Week 10-11)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies Graph
|
||||
|
||||
```
|
||||
Week 7 (AI Engine):
|
||||
Task 7.1 (Redis) → 7.2 (RQ) → 7.3 (AI jobs) → 7.4 (✅ Verify)
|
||||
Task 7.5 (Replicate+Claude) → 7.7 (Model selector) → 7.8 (✅ Verify)
|
||||
│
|
||||
Task 7.9 (Templates) ───────────────────┘→ 7.10 (Narrative gen) → 7.11 (AI jobs) → 7.12 (✅ Verify)
|
||||
│
|
||||
Task 7.13 (Usage track) → 7.14 (Rate limits) ────────────────────────┘
|
||||
└→ 7.15 (Cost monitoring)
|
||||
|
||||
Note: Task 7.6 merged into 7.5 (all models via Replicate API)
|
||||
|
||||
Week 8 (Story Progression):
|
||||
Task 8.16 (ActionPrompt) → 8.17 (YAML) → 8.18 (Loader) → 8.19 (✅ Verify)
|
||||
│
|
||||
Task 8.20 (GameSession) → 8.21 (SessionService) ──────────┤
|
||||
└→ 8.22 (History) ──────────────────┤
|
||||
└→ 8.23 (State tracking) → 8.24 (✅ Verify)
|
||||
│
|
||||
Task 8.25 (Create API) ───────────────────────────┤
|
||||
Task 8.26 (Action API) ───────────────────────────┤
|
||||
Task 8.27 (State API) ────────────────────────────┤
|
||||
Task 8.28 (History API) ──────────────────────────┤
|
||||
│
|
||||
Task 8.29 (Story UI) → 8.30 (Action buttons) → 8.31 (✅ Integration test)
|
||||
|
||||
Week 9 (Quest System):
|
||||
Task 9.32 (Quest models) → 9.33 (Triggers) → 9.34 (✅ Verify)
|
||||
│
|
||||
Task 9.35 (YAML schema) → 9.36 (10 quests) → 9.37 (QuestService) → 9.38 (✅ Verify)
|
||||
│
|
||||
Task 9.39 (Offering logic) ──────────────────────────────────────────┤
|
||||
Task 9.40 (Story integration) → 9.41 (Accept API) → 9.42 (Complete API)
|
||||
│
|
||||
Task 9.43 (Tracker UI) ──────────────────────────────┤
|
||||
Task 9.44 (Offer modal) ─────────────────────────────┤
|
||||
│
|
||||
Task 9.45 (✅ Final integration test)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
**Testing Strategy:**
|
||||
- Unit tests bundled into each implementation task
|
||||
- Integration tests at verification checkpoints
|
||||
- Manual testing for UI/UX flows
|
||||
- Use docs/API_TESTING.md for endpoint testing
|
||||
|
||||
**Cost Management:**
|
||||
- Target: < $500/month total AI costs
|
||||
- Free tier users cost $0 (Replicate)
|
||||
- Monitor daily costs via Task 7.15
|
||||
- Adjust tier limits if costs spike
|
||||
|
||||
**Development Tips:**
|
||||
- Start each week by reviewing previous week's work
|
||||
- Commit frequently with conventional commit messages
|
||||
- Update API_REFERENCE.md as you build endpoints
|
||||
- Test with real AI calls periodically (not just mocks)
|
||||
- Keep YAML files well-documented and validated
|
||||
|
||||
**Estimated Timeline:**
|
||||
- Week 7: ~40 hours (5 days at 8 hours/day)
|
||||
- Week 8: ~44 hours (5.5 days at 8 hours/day)
|
||||
- Week 9: ~42 hours (5.25 days at 8 hours/day)
|
||||
- **Total: ~126 hours (~16 days of focused work)**
|
||||
|
||||
**Success Metrics:**
|
||||
- All 45 tasks completed
|
||||
- All verification checkpoints passed
|
||||
- No critical bugs in core gameplay loop
|
||||
- AI costs within budget (<$50/day during development)
|
||||
- Story progression feels engaging and responsive
|
||||
- Quest system feels natural and rewarding
|
||||
|
||||
---
|
||||
|
||||
**Document History:**
|
||||
- v1.0 (2025-11-16): Initial Phase 4 implementation plan created
|
||||
530
api/docs/PROMPT_TEMPLATES.md
Normal file
530
api/docs/PROMPT_TEMPLATES.md
Normal file
@@ -0,0 +1,530 @@
|
||||
# Prompt Templates Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The prompt template system uses Jinja2 to build consistent, well-structured prompts for AI generation. Templates separate prompt logic from application code, making prompts easy to iterate and maintain.
|
||||
|
||||
**Location:** `app/ai/prompt_templates.py` and `app/ai/templates/*.j2`
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
PromptTemplates (singleton)
|
||||
├── Jinja2 Environment
|
||||
│ ├── FileSystemLoader (templates directory)
|
||||
│ ├── Custom filters (format_inventory, etc.)
|
||||
│ └── Global functions (len, min, max)
|
||||
└── Templates
|
||||
├── story_action.j2
|
||||
├── combat_action.j2
|
||||
├── quest_offering.j2
|
||||
└── npc_dialogue.j2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Quick Start
|
||||
|
||||
```python
|
||||
from app.ai.prompt_templates import render_prompt
|
||||
|
||||
# Render a template with context
|
||||
prompt = render_prompt(
|
||||
"story_action.j2",
|
||||
character={"name": "Aldric", "level": 3, "player_class": "Fighter", ...},
|
||||
action="I search for hidden doors",
|
||||
game_state={"current_location": "Ancient Library", ...}
|
||||
)
|
||||
```
|
||||
|
||||
### Using the PromptTemplates Class
|
||||
|
||||
```python
|
||||
from app.ai.prompt_templates import get_prompt_templates, PromptTemplates
|
||||
|
||||
# Get singleton instance
|
||||
templates = get_prompt_templates()
|
||||
|
||||
# Or create custom instance with different directory
|
||||
templates = PromptTemplates(template_dir="/path/to/custom/templates")
|
||||
|
||||
# Render template
|
||||
prompt = templates.render("story_action.j2", **context)
|
||||
|
||||
# Render from string
|
||||
prompt = templates.render_string(
|
||||
"Hello {{ name }}, you are level {{ level }}",
|
||||
name="Aldric",
|
||||
level=3
|
||||
)
|
||||
|
||||
# List available templates
|
||||
available = templates.get_template_names()
|
||||
# ['story_action.j2', 'combat_action.j2', ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Templates
|
||||
|
||||
### 1. Story Action (`story_action.j2`)
|
||||
|
||||
Generates DM responses to player story actions.
|
||||
|
||||
**Required Context:**
|
||||
- `character` - Character dict with name, level, player_class, stats, current_hp, max_hp
|
||||
- `game_state` - Dict with current_location, location_type, active_quests
|
||||
- `action` - String describing the player's action
|
||||
|
||||
**Optional Context:**
|
||||
- `conversation_history` - List of recent turns (turn, action, dm_response)
|
||||
- `world_context` - Additional world information
|
||||
- `action_instructions` - Action-specific AI instructions from `dm_prompt_template` in action_prompts.yaml
|
||||
- `max_tokens` - Token limit for response length guidance
|
||||
|
||||
**Player Agency Rules:**
|
||||
|
||||
The template includes critical rules to ensure AI respects player choice:
|
||||
- Never make decisions for the player (no auto-purchasing)
|
||||
- Never complete transactions without consent
|
||||
- Present options and ask what they want to do
|
||||
- If items have costs, show prices and ask to proceed
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
prompt = render_prompt(
|
||||
"story_action.j2",
|
||||
character={
|
||||
"name": "Aldric",
|
||||
"level": 3,
|
||||
"player_class": "Fighter",
|
||||
"current_hp": 25,
|
||||
"max_hp": 30,
|
||||
"stats": {"strength": 16, "dexterity": 14, "constitution": 15},
|
||||
"skills": [{"name": "Athletics", "level": 2}],
|
||||
"effects": [{"name": "Blessed", "remaining_turns": 3}]
|
||||
},
|
||||
game_state={
|
||||
"current_location": "Ancient Library",
|
||||
"location_type": "DUNGEON",
|
||||
"active_quests": ["find_artifact"],
|
||||
"discovered_locations": ["Village", "Forest"],
|
||||
"time_of_day": "Evening"
|
||||
},
|
||||
action="I search the room for hidden doors",
|
||||
conversation_history=[
|
||||
{"turn": 1, "action": "entered library", "dm_response": "You push open the heavy oak doors..."},
|
||||
{"turn": 2, "action": "examined shelves", "dm_response": "The shelves contain dusty tomes..."}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
**Output Structure:**
|
||||
- Character status (HP, stats, skills, effects)
|
||||
- Current situation (location, quests, time)
|
||||
- Recent history (last 3 turns)
|
||||
- Player action
|
||||
- Action-specific instructions (if provided)
|
||||
- Generation task with length guidance
|
||||
- Player agency rules
|
||||
|
||||
---
|
||||
|
||||
### 2. Combat Action (`combat_action.j2`)
|
||||
|
||||
Narrates combat actions with dramatic flair.
|
||||
|
||||
**Required Context:**
|
||||
- `character` - Character dict
|
||||
- `combat_state` - Dict with enemies, round_number, current_turn
|
||||
- `action` - Combat action description
|
||||
- `action_result` - Dict with hit, damage, effects_applied, target
|
||||
|
||||
**Optional Context:**
|
||||
- `is_critical` - Boolean for critical hits
|
||||
- `is_finishing_blow` - Boolean if enemy is defeated
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
prompt = render_prompt(
|
||||
"combat_action.j2",
|
||||
character={
|
||||
"name": "Aldric",
|
||||
"level": 3,
|
||||
"player_class": "Fighter",
|
||||
"current_hp": 20,
|
||||
"max_hp": 30,
|
||||
"effects": []
|
||||
},
|
||||
combat_state={
|
||||
"round_number": 3,
|
||||
"current_turn": "Aldric",
|
||||
"enemies": [
|
||||
{"name": "Goblin Chief", "current_hp": 8, "max_hp": 25, "effects": []}
|
||||
]
|
||||
},
|
||||
action="swings their longsword at the Goblin Chief",
|
||||
action_result={
|
||||
"hit": True,
|
||||
"damage": 12,
|
||||
"effects_applied": ["bleeding"],
|
||||
"target": "Goblin Chief"
|
||||
},
|
||||
is_critical=True,
|
||||
is_finishing_blow=False
|
||||
)
|
||||
```
|
||||
|
||||
**Output Structure:**
|
||||
- Combatants (player and enemies with HP/effects)
|
||||
- Combat round and turn
|
||||
- Action and result
|
||||
- Narrative instructions (1-2 paragraphs)
|
||||
|
||||
---
|
||||
|
||||
### 3. Quest Offering (`quest_offering.j2`)
|
||||
|
||||
AI selects the most contextually appropriate quest.
|
||||
|
||||
**Required Context:**
|
||||
- `character` - Character dict with completed_quests
|
||||
- `eligible_quests` - List of quest dicts
|
||||
- `game_context` - Dict with current_location, location_type, active_quests
|
||||
|
||||
**Optional Context:**
|
||||
- `recent_actions` - List of recent player action strings
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
prompt = render_prompt(
|
||||
"quest_offering.j2",
|
||||
character={
|
||||
"name": "Aldric",
|
||||
"level": 3,
|
||||
"player_class": "Fighter",
|
||||
"completed_quests": ["tutorial_quest"]
|
||||
},
|
||||
eligible_quests=[
|
||||
{
|
||||
"quest_id": "goblin_cave",
|
||||
"name": "Clear the Goblin Cave",
|
||||
"difficulty": "EASY",
|
||||
"quest_giver": "Village Elder",
|
||||
"description": "A nearby cave has been overrun by goblins...",
|
||||
"narrative_hooks": [
|
||||
"The village elder looks worried about recent goblin attacks",
|
||||
"Farmers complain about lost livestock"
|
||||
]
|
||||
},
|
||||
{
|
||||
"quest_id": "herb_gathering",
|
||||
"name": "Gather Healing Herbs",
|
||||
"difficulty": "EASY",
|
||||
"quest_giver": "Herbalist",
|
||||
"description": "The local herbalist needs rare herbs...",
|
||||
"narrative_hooks": [
|
||||
"The herbalist mentions a shortage of supplies",
|
||||
"You notice the apothecary shelves are nearly bare"
|
||||
]
|
||||
}
|
||||
],
|
||||
game_context={
|
||||
"current_location": "The Rusty Anchor Tavern",
|
||||
"location_type": "TAVERN",
|
||||
"active_quests": [],
|
||||
"world_events": ["goblin raids increasing"]
|
||||
},
|
||||
recent_actions=["asked about rumors", "talked to locals"]
|
||||
)
|
||||
```
|
||||
|
||||
**Output:** Just the quest_id (e.g., `goblin_cave`)
|
||||
|
||||
---
|
||||
|
||||
### 4. NPC Dialogue (`npc_dialogue.j2`)
|
||||
|
||||
Generates contextual NPC conversations.
|
||||
|
||||
**Required Context:**
|
||||
- `character` - Player character dict
|
||||
- `npc` - NPC dict with name, role, personality
|
||||
- `conversation_topic` - What the player wants to discuss
|
||||
- `game_state` - Current game state
|
||||
|
||||
**Optional Context:**
|
||||
- `npc_relationship` - Description of relationship
|
||||
- `previous_dialogue` - List of exchanges (player_line, npc_response)
|
||||
- `npc_knowledge` - List of things this NPC knows
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
prompt = render_prompt(
|
||||
"npc_dialogue.j2",
|
||||
character={
|
||||
"name": "Aldric",
|
||||
"level": 3,
|
||||
"player_class": "Fighter"
|
||||
},
|
||||
npc={
|
||||
"name": "Old Barkeep",
|
||||
"role": "Tavern Owner",
|
||||
"personality": "Gruff but kind-hearted",
|
||||
"speaking_style": "Short sentences, occasional grunt",
|
||||
"goals": "Keep the tavern running, protect the village",
|
||||
"secret_knowledge": "Knows about the hidden cellar entrance"
|
||||
},
|
||||
conversation_topic="What rumors have you heard lately?",
|
||||
game_state={
|
||||
"current_location": "The Rusty Anchor",
|
||||
"time_of_day": "Evening",
|
||||
"active_quests": []
|
||||
},
|
||||
npc_relationship="Acquaintance - met twice before",
|
||||
npc_knowledge=["goblin attacks", "missing merchant", "ancient ruins"],
|
||||
previous_dialogue=[
|
||||
{
|
||||
"player_line": "I'll have an ale",
|
||||
"npc_response": "*slides a mug across the bar* Two copper. You new around here?"
|
||||
}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
**Output:** NPC dialogue with action/emotion tags
|
||||
Format: `*action* "Dialogue here."`
|
||||
|
||||
---
|
||||
|
||||
## Custom Filters
|
||||
|
||||
Templates have access to these formatting filters:
|
||||
|
||||
### `format_inventory`
|
||||
|
||||
Formats item lists with quantities.
|
||||
|
||||
```jinja2
|
||||
{{ items | format_inventory }}
|
||||
{# Output: "Health Potion (x3), Sword, Shield, and 5 more items" #}
|
||||
```
|
||||
|
||||
**Parameters:** `max_items` (default 10)
|
||||
|
||||
### `format_stats`
|
||||
|
||||
Formats stat dictionaries.
|
||||
|
||||
```jinja2
|
||||
{{ character.stats | format_stats }}
|
||||
{# Output: "Strength: 16, Dexterity: 14, Constitution: 15" #}
|
||||
```
|
||||
|
||||
### `format_skills`
|
||||
|
||||
Formats skill lists with levels.
|
||||
|
||||
```jinja2
|
||||
{{ character.skills | format_skills }}
|
||||
{# Output: "Athletics (Lv.2), Perception (Lv.3), and 2 more skills" #}
|
||||
```
|
||||
|
||||
**Parameters:** `max_skills` (default 5)
|
||||
|
||||
### `format_effects`
|
||||
|
||||
Formats active effects/buffs/debuffs.
|
||||
|
||||
```jinja2
|
||||
{{ character.effects | format_effects }}
|
||||
{# Output: "Blessed (3 turns), Poisoned (2 turns)" #}
|
||||
```
|
||||
|
||||
### `truncate_text`
|
||||
|
||||
Truncates long text with ellipsis.
|
||||
|
||||
```jinja2
|
||||
{{ long_description | truncate_text(100) }}
|
||||
{# Output: "This is a very long description that will be cut off..." #}
|
||||
```
|
||||
|
||||
**Parameters:** `max_length` (default 100)
|
||||
|
||||
### `format_gold`
|
||||
|
||||
Formats currency with commas.
|
||||
|
||||
```jinja2
|
||||
{{ 10000 | format_gold }}
|
||||
{# Output: "10,000 gold" #}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Global Functions
|
||||
|
||||
Available in all templates:
|
||||
|
||||
```jinja2
|
||||
{{ len(items) }} {# Length of list #}
|
||||
{{ min(a, b) }} {# Minimum value #}
|
||||
{{ max(a, b) }} {# Maximum value #}
|
||||
{% for i, item in enumerate(items) %}
|
||||
{{ i }}: {{ item }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating New Templates
|
||||
|
||||
### Template Structure
|
||||
|
||||
1. Create file in `app/ai/templates/` with `.j2` extension
|
||||
2. Add documentation header with required/optional context
|
||||
3. Structure the prompt with clear sections
|
||||
4. End with task instructions
|
||||
|
||||
**Template Example:**
|
||||
```jinja2
|
||||
{#
|
||||
My Custom Template
|
||||
Description of what this template does.
|
||||
|
||||
Required context:
|
||||
- param1: Description
|
||||
- param2: Description
|
||||
|
||||
Optional context:
|
||||
- param3: Description
|
||||
#}
|
||||
|
||||
## Section Header
|
||||
Content using {{ param1 }}
|
||||
|
||||
{% if param3 %}
|
||||
## Optional Section
|
||||
{{ param3 }}
|
||||
{% endif %}
|
||||
|
||||
## Task Instructions
|
||||
Tell the AI what to do...
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Clear documentation** - Always document required/optional context
|
||||
2. **Structured output** - Use markdown headers for readability
|
||||
3. **Graceful fallbacks** - Use `{% if %}` for optional fields
|
||||
4. **Concise prompts** - Include only necessary context
|
||||
5. **Explicit instructions** - Tell AI exactly what to output
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from app.ai.prompt_templates import PromptTemplateError
|
||||
|
||||
try:
|
||||
prompt = render_prompt("unknown.j2", **context)
|
||||
except PromptTemplateError as e:
|
||||
logger.error(f"Template error: {e}")
|
||||
# Handle missing template or rendering error
|
||||
```
|
||||
|
||||
Common errors:
|
||||
- Template not found
|
||||
- Missing required context variables
|
||||
- Filter errors (e.g., None passed to filter)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Template Directory
|
||||
|
||||
Default: `app/ai/templates/`
|
||||
|
||||
Custom directory:
|
||||
```python
|
||||
templates = PromptTemplates(template_dir="/custom/path")
|
||||
```
|
||||
|
||||
### Jinja2 Settings
|
||||
|
||||
- `trim_blocks=True` - Remove newline after block tags
|
||||
- `lstrip_blocks=True` - Remove leading whitespace before block tags
|
||||
- `autoescape=True` - Escape HTML/XML (security)
|
||||
|
||||
---
|
||||
|
||||
## Integration with NarrativeGenerator
|
||||
|
||||
The `NarrativeGenerator` uses templates internally:
|
||||
|
||||
```python
|
||||
from app.ai.narrative_generator import NarrativeGenerator
|
||||
|
||||
generator = NarrativeGenerator()
|
||||
|
||||
# Uses story_action.j2 internally
|
||||
response = generator.generate_story_response(
|
||||
character=character,
|
||||
action=action,
|
||||
game_state=game_state,
|
||||
user_tier=UserTier.PREMIUM
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Templates
|
||||
|
||||
### Unit Test Pattern
|
||||
|
||||
```python
|
||||
from app.ai.prompt_templates import PromptTemplates
|
||||
|
||||
def test_story_action_template():
|
||||
templates = PromptTemplates()
|
||||
|
||||
context = {
|
||||
"character": {
|
||||
"name": "Test",
|
||||
"level": 1,
|
||||
"player_class": "Fighter",
|
||||
"current_hp": 10,
|
||||
"max_hp": 10,
|
||||
"stats": {"strength": 10}
|
||||
},
|
||||
"game_state": {
|
||||
"current_location": "Test Location",
|
||||
"location_type": "TOWN"
|
||||
},
|
||||
"action": "Test action"
|
||||
}
|
||||
|
||||
prompt = templates.render("story_action.j2", **context)
|
||||
|
||||
assert "Test" in prompt
|
||||
assert "Fighter" in prompt
|
||||
assert "Test action" in prompt
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
```python
|
||||
from app.ai.prompt_templates import render_prompt
|
||||
|
||||
# Render and print to inspect
|
||||
prompt = render_prompt("story_action.j2", **test_context)
|
||||
print(prompt)
|
||||
print(f"\nLength: {len(prompt)} chars")
|
||||
```
|
||||
927
api/docs/QUEST_SYSTEM.md
Normal file
927
api/docs/QUEST_SYSTEM.md
Normal file
@@ -0,0 +1,927 @@
|
||||
# Quest System
|
||||
|
||||
**Status:** Planned
|
||||
**Phase:** 4 (AI Integration + Story Progression)
|
||||
**Timeline:** Week 9 of Phase 4 (Days 13-14)
|
||||
**Last Updated:** November 16, 2025
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Quest System provides structured objectives and rewards for players during their solo story progression sessions. Quests are defined in YAML files and offered to players by the AI Dungeon Master based on context-aware triggers and location-based probability.
|
||||
|
||||
**Key Principles:**
|
||||
- **YAML-driven design** - Quests defined in data files, no code changes needed
|
||||
- **Context-aware offering** - AI analyzes narrative context to offer relevant quests
|
||||
- **Location-based triggers** - Different areas have different quest probabilities
|
||||
- **Max 2 active quests** - Prevents player overwhelm
|
||||
- **Random but meaningful** - Quest offering feels natural, not forced
|
||||
- **Rewarding progression** - Quests provide gold, XP, and items
|
||||
|
||||
---
|
||||
|
||||
## Quest Structure
|
||||
|
||||
### Quest Components
|
||||
|
||||
A quest consists of:
|
||||
|
||||
1. **Metadata** - Name, description, difficulty, quest giver
|
||||
2. **Objectives** - Specific goals to complete (ordered or unordered)
|
||||
3. **Rewards** - Gold, XP, items awarded upon completion
|
||||
4. **Offering Triggers** - Context and location requirements
|
||||
5. **Narrative Hooks** - Story fragments for AI to use in offering
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### Quest Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Quest:
|
||||
"""Represents a quest with objectives and rewards."""
|
||||
|
||||
quest_id: str # Unique identifier (e.g., "quest_rats_tavern")
|
||||
name: str # Display name (e.g., "Rat Problem")
|
||||
description: str # Full quest description
|
||||
quest_giver: str # NPC or source (e.g., "Tavern Keeper")
|
||||
difficulty: str # "easy", "medium", "hard", "epic"
|
||||
objectives: List[QuestObjective] # List of objectives to complete
|
||||
rewards: QuestReward # Rewards for completion
|
||||
offering_triggers: QuestTriggers # When/where quest can be offered
|
||||
narrative_hooks: List[str] # Story snippets for AI to use
|
||||
status: str = "available" # "available", "active", "completed", "failed"
|
||||
progress: Dict[str, Any] = field(default_factory=dict) # Objective progress tracking
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""Check if all objectives are completed."""
|
||||
return all(obj.completed for obj in self.objectives)
|
||||
|
||||
def get_next_objective(self) -> Optional[QuestObjective]:
|
||||
"""Get the next incomplete objective."""
|
||||
for obj in self.objectives:
|
||||
if not obj.completed:
|
||||
return obj
|
||||
return None
|
||||
|
||||
def update_progress(self, objective_id: str, progress_value: int) -> None:
|
||||
"""Update progress for a specific objective."""
|
||||
for obj in self.objectives:
|
||||
if obj.objective_id == objective_id:
|
||||
obj.current_progress = min(progress_value, obj.required_progress)
|
||||
if obj.current_progress >= obj.required_progress:
|
||||
obj.completed = True
|
||||
break
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary for JSON storage."""
|
||||
return {
|
||||
"quest_id": self.quest_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"quest_giver": self.quest_giver,
|
||||
"difficulty": self.difficulty,
|
||||
"objectives": [obj.to_dict() for obj in self.objectives],
|
||||
"rewards": self.rewards.to_dict(),
|
||||
"offering_triggers": self.offering_triggers.to_dict(),
|
||||
"narrative_hooks": self.narrative_hooks,
|
||||
"status": self.status,
|
||||
"progress": self.progress
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Quest':
|
||||
"""Deserialize from dictionary."""
|
||||
return cls(
|
||||
quest_id=data["quest_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
quest_giver=data["quest_giver"],
|
||||
difficulty=data["difficulty"],
|
||||
objectives=[QuestObjective.from_dict(obj) for obj in data["objectives"]],
|
||||
rewards=QuestReward.from_dict(data["rewards"]),
|
||||
offering_triggers=QuestTriggers.from_dict(data["offering_triggers"]),
|
||||
narrative_hooks=data["narrative_hooks"],
|
||||
status=data.get("status", "available"),
|
||||
progress=data.get("progress", {})
|
||||
)
|
||||
```
|
||||
|
||||
### QuestObjective Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class QuestObjective:
|
||||
"""Represents a single objective within a quest."""
|
||||
|
||||
objective_id: str # Unique ID (e.g., "kill_rats")
|
||||
description: str # Player-facing description
|
||||
objective_type: str # "kill", "collect", "travel", "interact", "discover"
|
||||
required_progress: int # Target value (e.g., 10 rats)
|
||||
current_progress: int = 0 # Current value (e.g., 5 rats killed)
|
||||
completed: bool = False # Objective completion status
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"objective_id": self.objective_id,
|
||||
"description": self.description,
|
||||
"objective_type": self.objective_type,
|
||||
"required_progress": self.required_progress,
|
||||
"current_progress": self.current_progress,
|
||||
"completed": self.completed
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestObjective':
|
||||
return cls(
|
||||
objective_id=data["objective_id"],
|
||||
description=data["description"],
|
||||
objective_type=data["objective_type"],
|
||||
required_progress=data["required_progress"],
|
||||
current_progress=data.get("current_progress", 0),
|
||||
completed=data.get("completed", False)
|
||||
)
|
||||
```
|
||||
|
||||
### QuestReward Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class QuestReward:
|
||||
"""Rewards granted upon quest completion."""
|
||||
|
||||
gold: int = 0 # Gold reward
|
||||
experience: int = 0 # XP reward
|
||||
items: List[str] = field(default_factory=list) # Item IDs to grant
|
||||
reputation: Optional[str] = None # Reputation faction (future feature)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"gold": self.gold,
|
||||
"experience": self.experience,
|
||||
"items": self.items,
|
||||
"reputation": self.reputation
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestReward':
|
||||
return cls(
|
||||
gold=data.get("gold", 0),
|
||||
experience=data.get("experience", 0),
|
||||
items=data.get("items", []),
|
||||
reputation=data.get("reputation")
|
||||
)
|
||||
```
|
||||
|
||||
### QuestTriggers Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class QuestTriggers:
|
||||
"""Defines when and where a quest can be offered."""
|
||||
|
||||
location_types: List[str] # ["town", "wilderness", "dungeon"] or ["any"]
|
||||
specific_locations: List[str] # Specific location IDs or empty for any
|
||||
min_character_level: int = 1 # Minimum level required
|
||||
max_character_level: int = 100 # Maximum level (for scaling)
|
||||
required_quests_completed: List[str] = field(default_factory=list) # Quest prerequisites
|
||||
probability_weights: Dict[str, float] = field(default_factory=dict) # Location-specific chances
|
||||
|
||||
def get_offer_probability(self, location_type: str) -> float:
|
||||
"""Get the probability of offering this quest at location type."""
|
||||
return self.probability_weights.get(location_type, 0.0)
|
||||
|
||||
def can_offer(self, character_level: int, location: str, location_type: str, completed_quests: List[str]) -> bool:
|
||||
"""Check if quest can be offered to this character at this location."""
|
||||
|
||||
# Check level requirements
|
||||
if character_level < self.min_character_level or character_level > self.max_character_level:
|
||||
return False
|
||||
|
||||
# Check quest prerequisites
|
||||
for required_quest in self.required_quests_completed:
|
||||
if required_quest not in completed_quests:
|
||||
return False
|
||||
|
||||
# Check location type
|
||||
if "any" not in self.location_types and location_type not in self.location_types:
|
||||
return False
|
||||
|
||||
# Check specific location (if specified)
|
||||
if self.specific_locations and location not in self.specific_locations:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"location_types": self.location_types,
|
||||
"specific_locations": self.specific_locations,
|
||||
"min_character_level": self.min_character_level,
|
||||
"max_character_level": self.max_character_level,
|
||||
"required_quests_completed": self.required_quests_completed,
|
||||
"probability_weights": self.probability_weights
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestTriggers':
|
||||
return cls(
|
||||
location_types=data["location_types"],
|
||||
specific_locations=data.get("specific_locations", []),
|
||||
min_character_level=data.get("min_character_level", 1),
|
||||
max_character_level=data.get("max_character_level", 100),
|
||||
required_quests_completed=data.get("required_quests_completed", []),
|
||||
probability_weights=data.get("probability_weights", {})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## YAML Quest Definitions
|
||||
|
||||
Quests are stored in `/app/data/quests/` organized by difficulty:
|
||||
|
||||
```
|
||||
/app/data/quests/
|
||||
├── easy/
|
||||
│ ├── rat_problem.yaml
|
||||
│ ├── delivery_run.yaml
|
||||
│ └── missing_cat.yaml
|
||||
├── medium/
|
||||
│ ├── bandit_camp.yaml
|
||||
│ ├── haunted_ruins.yaml
|
||||
│ └── merchant_escort.yaml
|
||||
├── hard/
|
||||
│ ├── dragon_lair.yaml
|
||||
│ ├── necromancer_tower.yaml
|
||||
│ └── lost_artifact.yaml
|
||||
└── epic/
|
||||
├── demon_invasion.yaml
|
||||
└── ancient_prophecy.yaml
|
||||
```
|
||||
|
||||
### Example Quest YAML: Rat Problem (Easy)
|
||||
|
||||
**File:** `/app/data/quests/easy/rat_problem.yaml`
|
||||
|
||||
```yaml
|
||||
quest_id: "quest_rats_tavern"
|
||||
name: "Rat Problem"
|
||||
description: "The local tavern is overrun with giant rats. The tavern keeper needs someone to clear them out before they scare away all the customers."
|
||||
quest_giver: "Tavern Keeper"
|
||||
difficulty: "easy"
|
||||
|
||||
objectives:
|
||||
- objective_id: "kill_rats"
|
||||
description: "Kill 10 giant rats in the tavern basement"
|
||||
objective_type: "kill"
|
||||
required_progress: 10
|
||||
|
||||
rewards:
|
||||
gold: 50
|
||||
experience: 100
|
||||
items: []
|
||||
|
||||
offering_triggers:
|
||||
location_types: ["town"]
|
||||
specific_locations: [] # Any town
|
||||
min_character_level: 1
|
||||
max_character_level: 3
|
||||
required_quests_completed: []
|
||||
probability_weights:
|
||||
town: 0.30 # 30% chance in towns
|
||||
wilderness: 0.0 # 0% chance in wilderness
|
||||
dungeon: 0.0 # 0% chance in dungeons
|
||||
|
||||
narrative_hooks:
|
||||
- "The tavern keeper frantically waves you over, mentioning strange noises from the basement."
|
||||
- "You overhear patrons complaining about rat infestations ruining the food supplies."
|
||||
- "A desperate-looking innkeeper approaches you, begging for help with a pest problem."
|
||||
```
|
||||
|
||||
### Example Quest YAML: Bandit Camp (Medium)
|
||||
|
||||
**File:** `/app/data/quests/medium/bandit_camp.yaml`
|
||||
|
||||
```yaml
|
||||
quest_id: "quest_bandit_camp"
|
||||
name: "Clear the Bandit Camp"
|
||||
description: "A group of bandits has been raiding merchant caravans along the main road. The town guard wants someone to clear out their camp in the nearby woods."
|
||||
quest_giver: "Captain of the Guard"
|
||||
difficulty: "medium"
|
||||
|
||||
objectives:
|
||||
- objective_id: "find_camp"
|
||||
description: "Locate the bandit camp in the forest"
|
||||
objective_type: "discover"
|
||||
required_progress: 1
|
||||
|
||||
- objective_id: "defeat_bandits"
|
||||
description: "Defeat the bandit leader and their gang"
|
||||
objective_type: "kill"
|
||||
required_progress: 1
|
||||
|
||||
- objective_id: "return_goods"
|
||||
description: "Return stolen goods to the town"
|
||||
objective_type: "interact"
|
||||
required_progress: 1
|
||||
|
||||
rewards:
|
||||
gold: 200
|
||||
experience: 500
|
||||
items: ["iron_sword", "leather_armor"]
|
||||
|
||||
offering_triggers:
|
||||
location_types: ["town"]
|
||||
specific_locations: []
|
||||
min_character_level: 3
|
||||
max_character_level: 7
|
||||
required_quests_completed: []
|
||||
probability_weights:
|
||||
town: 0.20
|
||||
wilderness: 0.05
|
||||
dungeon: 0.0
|
||||
|
||||
narrative_hooks:
|
||||
- "The captain of the guard summons you, speaking urgently about increased bandit activity."
|
||||
- "A merchant tells you tales of lost caravans and a hidden camp somewhere in the eastern woods."
|
||||
- "Wanted posters line the town walls, offering rewards for dealing with the bandit menace."
|
||||
```
|
||||
|
||||
### Example Quest YAML: Dragon's Lair (Hard)
|
||||
|
||||
**File:** `/app/data/quests/hard/dragon_lair.yaml`
|
||||
|
||||
```yaml
|
||||
quest_id: "quest_dragon_lair"
|
||||
name: "The Dragon's Lair"
|
||||
description: "An ancient red dragon has awakened in the mountains and is terrorizing the region. The kingdom offers a substantial reward for anyone brave enough to slay the beast."
|
||||
quest_giver: "Royal Herald"
|
||||
difficulty: "hard"
|
||||
|
||||
objectives:
|
||||
- objective_id: "gather_info"
|
||||
description: "Gather information about the dragon's lair"
|
||||
objective_type: "interact"
|
||||
required_progress: 3
|
||||
|
||||
- objective_id: "find_lair"
|
||||
description: "Locate the dragon's lair in the mountains"
|
||||
objective_type: "discover"
|
||||
required_progress: 1
|
||||
|
||||
- objective_id: "slay_dragon"
|
||||
description: "Defeat the ancient red dragon"
|
||||
objective_type: "kill"
|
||||
required_progress: 1
|
||||
|
||||
- objective_id: "claim_hoard"
|
||||
description: "Claim a portion of the dragon's hoard"
|
||||
objective_type: "collect"
|
||||
required_progress: 1
|
||||
|
||||
rewards:
|
||||
gold: 5000
|
||||
experience: 10000
|
||||
items: ["dragon_scale_armor", "flaming_longsword", "ring_of_fire_resistance"]
|
||||
|
||||
offering_triggers:
|
||||
location_types: ["town"]
|
||||
specific_locations: ["capital_city", "mountain_fortress"]
|
||||
min_character_level: 10
|
||||
max_character_level: 100
|
||||
required_quests_completed: []
|
||||
probability_weights:
|
||||
town: 0.10
|
||||
wilderness: 0.02
|
||||
dungeon: 0.0
|
||||
|
||||
narrative_hooks:
|
||||
- "A royal herald announces a call to arms - a dragon threatens the kingdom!"
|
||||
- "You hear tales of entire villages razed by dragonfire in the northern mountains."
|
||||
- "The king's messenger seeks experienced adventurers for a quest of utmost danger."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quest Offering Logic
|
||||
|
||||
### When Quests Are Offered
|
||||
|
||||
Quests are checked for offering after each story turn action, using a two-stage process:
|
||||
|
||||
#### Stage 1: Location-Based Probability Roll
|
||||
|
||||
```python
|
||||
def roll_for_quest_offering(location_type: str) -> bool:
|
||||
"""Roll to see if any quest should be offered this turn."""
|
||||
|
||||
base_probabilities = {
|
||||
"town": 0.30, # 30% chance in towns/cities
|
||||
"tavern": 0.35, # 35% chance in taverns (special location)
|
||||
"wilderness": 0.05, # 5% chance in wilderness
|
||||
"dungeon": 0.10, # 10% chance in dungeons
|
||||
}
|
||||
|
||||
chance = base_probabilities.get(location_type, 0.05)
|
||||
return random.random() < chance
|
||||
```
|
||||
|
||||
#### Stage 2: Context-Aware Selection
|
||||
|
||||
If the roll succeeds, the AI Dungeon Master analyzes the recent narrative context to select an appropriate quest:
|
||||
|
||||
```python
|
||||
def select_quest_from_context(
|
||||
available_quests: List[Quest],
|
||||
character: Character,
|
||||
session: GameSession,
|
||||
location: str,
|
||||
location_type: str
|
||||
) -> Optional[Quest]:
|
||||
"""Use AI to select a contextually appropriate quest."""
|
||||
|
||||
# Filter quests by eligibility
|
||||
eligible_quests = [
|
||||
q for q in available_quests
|
||||
if q.offering_triggers.can_offer(
|
||||
character.level,
|
||||
location,
|
||||
location_type,
|
||||
character.completed_quests
|
||||
)
|
||||
]
|
||||
|
||||
if not eligible_quests:
|
||||
return None
|
||||
|
||||
# Build context for AI decision
|
||||
context = {
|
||||
"character_name": character.name,
|
||||
"character_level": character.level,
|
||||
"location": location,
|
||||
"recent_actions": session.conversation_history[-3:],
|
||||
"available_quests": [
|
||||
{
|
||||
"quest_id": q.quest_id,
|
||||
"name": q.name,
|
||||
"narrative_hooks": q.narrative_hooks,
|
||||
"difficulty": q.difficulty
|
||||
}
|
||||
for q in eligible_quests
|
||||
]
|
||||
}
|
||||
|
||||
# Ask AI to select most fitting quest (or none)
|
||||
prompt = render_quest_selection_prompt(context)
|
||||
ai_response = call_ai_api(prompt)
|
||||
|
||||
selected_quest_id = parse_quest_selection(ai_response)
|
||||
|
||||
if selected_quest_id:
|
||||
return next(q for q in eligible_quests if q.quest_id == selected_quest_id)
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Quest Offering Flow Diagram
|
||||
|
||||
```
|
||||
After Story Turn Action
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Check Active Quests │
|
||||
│ If >= 2, skip offering │
|
||||
└────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Location-Based Roll │
|
||||
│ - Town: 30% │
|
||||
│ - Tavern: 35% │
|
||||
│ - Wilderness: 5% │
|
||||
│ - Dungeon: 10% │
|
||||
└────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
Roll Success?
|
||||
│
|
||||
┌────┴────┐
|
||||
No Yes
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────────┐
|
||||
│ │ Filter Eligible Quests │
|
||||
│ │ - Level requirements │
|
||||
│ │ - Location match │
|
||||
│ │ - Prerequisites met │
|
||||
│ └────────┬────────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ Any Eligible?
|
||||
│ │
|
||||
│ ┌────┴────┐
|
||||
│ No Yes
|
||||
│ │ │
|
||||
│ │ ▼
|
||||
│ │ ┌─────────────────────────┐
|
||||
│ │ │ AI Context Analysis │
|
||||
│ │ │ Select fitting quest │
|
||||
│ │ │ from narrative context │
|
||||
│ │ └────────┬────────────────┘
|
||||
│ │ │
|
||||
│ │ ▼
|
||||
│ │ Quest Selected?
|
||||
│ │ │
|
||||
│ │ ┌────┴────┐
|
||||
│ │ No Yes
|
||||
│ │ │ │
|
||||
│ │ │ ▼
|
||||
│ │ │ ┌─────────────────────────┐
|
||||
│ │ │ │ Offer Quest to Player │
|
||||
│ │ │ │ Display narrative hook │
|
||||
│ │ │ └─────────────────────────┘
|
||||
│ │ │
|
||||
└────────┴────────┴──► No Quest Offered
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quest Tracking and Completion
|
||||
|
||||
### Accepting a Quest
|
||||
|
||||
**Endpoint:** `POST /api/v1/quests/accept`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"character_id": "char_abc123",
|
||||
"quest_id": "quest_rats_tavern"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"quest": {
|
||||
"quest_id": "quest_rats_tavern",
|
||||
"name": "Rat Problem",
|
||||
"description": "The local tavern is overrun with giant rats...",
|
||||
"objectives": [
|
||||
{
|
||||
"objective_id": "kill_rats",
|
||||
"description": "Kill 10 giant rats in the tavern basement",
|
||||
"current_progress": 0,
|
||||
"required_progress": 10,
|
||||
"completed": false
|
||||
}
|
||||
],
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Updating Quest Progress
|
||||
|
||||
Quest progress is updated automatically during combat or story actions:
|
||||
|
||||
```python
|
||||
def update_quest_progress(character: Character, event_type: str, event_data: Dict) -> List[str]:
|
||||
"""Update quest progress based on game events."""
|
||||
|
||||
updated_quests = []
|
||||
|
||||
for quest_id in character.active_quests:
|
||||
quest = load_quest(quest_id)
|
||||
|
||||
for objective in quest.objectives:
|
||||
if objective.completed:
|
||||
continue
|
||||
|
||||
# Match event to objective type
|
||||
if objective.objective_type == "kill" and event_type == "enemy_killed":
|
||||
if matches_objective(objective, event_data):
|
||||
quest.update_progress(objective.objective_id, objective.current_progress + 1)
|
||||
updated_quests.append(quest_id)
|
||||
|
||||
elif objective.objective_type == "collect" and event_type == "item_obtained":
|
||||
if matches_objective(objective, event_data):
|
||||
quest.update_progress(objective.objective_id, objective.current_progress + 1)
|
||||
updated_quests.append(quest_id)
|
||||
|
||||
elif objective.objective_type == "discover" and event_type == "location_discovered":
|
||||
if matches_objective(objective, event_data):
|
||||
quest.update_progress(objective.objective_id, 1)
|
||||
updated_quests.append(quest_id)
|
||||
|
||||
elif objective.objective_type == "interact" and event_type == "npc_interaction":
|
||||
if matches_objective(objective, event_data):
|
||||
quest.update_progress(objective.objective_id, objective.current_progress + 1)
|
||||
updated_quests.append(quest_id)
|
||||
|
||||
# Check if quest is complete
|
||||
if quest.is_complete():
|
||||
complete_quest(character, quest)
|
||||
|
||||
return updated_quests
|
||||
```
|
||||
|
||||
### Completing a Quest
|
||||
|
||||
**Endpoint:** `POST /api/v1/quests/complete`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"character_id": "char_abc123",
|
||||
"quest_id": "quest_rats_tavern"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"quest_completed": true,
|
||||
"rewards": {
|
||||
"gold": 50,
|
||||
"experience": 100,
|
||||
"items": [],
|
||||
"level_up": false
|
||||
},
|
||||
"message": "You have completed the Rat Problem quest! The tavern keeper thanks you profusely."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quest Service
|
||||
|
||||
### QuestService Class
|
||||
|
||||
```python
|
||||
class QuestService:
|
||||
"""Service for managing quests."""
|
||||
|
||||
def __init__(self):
|
||||
self.quest_cache: Dict[str, Quest] = {}
|
||||
self._load_all_quests()
|
||||
|
||||
def _load_all_quests(self) -> None:
|
||||
"""Load all quests from YAML files."""
|
||||
quest_dirs = [
|
||||
"app/data/quests/easy/",
|
||||
"app/data/quests/medium/",
|
||||
"app/data/quests/hard/",
|
||||
"app/data/quests/epic/"
|
||||
]
|
||||
|
||||
for quest_dir in quest_dirs:
|
||||
for yaml_file in glob.glob(f"{quest_dir}*.yaml"):
|
||||
quest = self._load_quest_from_yaml(yaml_file)
|
||||
self.quest_cache[quest.quest_id] = quest
|
||||
|
||||
def _load_quest_from_yaml(self, filepath: str) -> Quest:
|
||||
"""Load a single quest from YAML file."""
|
||||
with open(filepath, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
return Quest.from_dict(data)
|
||||
|
||||
def get_quest(self, quest_id: str) -> Optional[Quest]:
|
||||
"""Get quest by ID."""
|
||||
return self.quest_cache.get(quest_id)
|
||||
|
||||
def get_available_quests(self, character: Character, location: str, location_type: str) -> List[Quest]:
|
||||
"""Get all quests available to character at location."""
|
||||
return [
|
||||
quest for quest in self.quest_cache.values()
|
||||
if quest.offering_triggers.can_offer(
|
||||
character.level,
|
||||
location,
|
||||
location_type,
|
||||
character.completed_quests
|
||||
)
|
||||
]
|
||||
|
||||
def accept_quest(self, character_id: str, quest_id: str) -> bool:
|
||||
"""Accept a quest for a character."""
|
||||
character = get_character(character_id)
|
||||
|
||||
# Check quest limit
|
||||
if len(character.active_quests) >= 2:
|
||||
raise QuestLimitExceeded("You can only have 2 active quests at a time")
|
||||
|
||||
# Add quest to active quests
|
||||
if quest_id not in character.active_quests:
|
||||
character.active_quests.append(quest_id)
|
||||
update_character(character)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def complete_quest(self, character_id: str, quest_id: str) -> QuestReward:
|
||||
"""Complete a quest and grant rewards."""
|
||||
character = get_character(character_id)
|
||||
quest = self.get_quest(quest_id)
|
||||
|
||||
if not quest or not quest.is_complete():
|
||||
raise QuestNotComplete("Quest objectives not complete")
|
||||
|
||||
# Grant rewards
|
||||
character.gold += quest.rewards.gold
|
||||
character.experience += quest.rewards.experience
|
||||
|
||||
for item_id in quest.rewards.items:
|
||||
item = load_item(item_id)
|
||||
character.inventory.append(item)
|
||||
|
||||
# Move quest to completed
|
||||
character.active_quests.remove(quest_id)
|
||||
character.completed_quests.append(quest_id)
|
||||
|
||||
# Check for level up
|
||||
leveled_up = check_level_up(character)
|
||||
|
||||
update_character(character)
|
||||
|
||||
return quest.rewards
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Integration
|
||||
|
||||
### Quest Tracker (Sidebar)
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ 🎯 Active Quests (1/2) │
|
||||
├─────────────────────────┤
|
||||
│ │
|
||||
│ Rat Problem │
|
||||
│ ─────────────── │
|
||||
│ ✅ Kill 10 giant rats │
|
||||
│ (7/10) │
|
||||
│ │
|
||||
│ [View Details] │
|
||||
│ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### Quest Offering Modal
|
||||
|
||||
When a quest is offered during a story turn:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Quest Offered! │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🎯 Rat Problem │
|
||||
│ │
|
||||
│ The tavern keeper frantically waves │
|
||||
│ you over, mentioning strange noises │
|
||||
│ from the basement. │
|
||||
│ │
|
||||
│ "Please, you must help! Giant rats │
|
||||
│ have overrun my cellar. I'll pay you │
|
||||
│ well to clear them out!" │
|
||||
│ │
|
||||
│ Difficulty: Easy │
|
||||
│ Rewards: 50 gold, 100 XP │
|
||||
│ │
|
||||
│ Objectives: │
|
||||
│ • Kill 10 giant rats │
|
||||
│ │
|
||||
│ [Accept Quest] [Decline] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Quest Detail View
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Rat Problem [Close] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Quest Giver: Tavern Keeper │
|
||||
│ Difficulty: Easy │
|
||||
│ │
|
||||
│ Description: │
|
||||
│ The local tavern is overrun with giant │
|
||||
│ rats. The tavern keeper needs someone │
|
||||
│ to clear them out before they scare │
|
||||
│ away all the customers. │
|
||||
│ │
|
||||
│ Objectives: │
|
||||
│ ✅ Kill 10 giant rats (7/10) │
|
||||
│ │
|
||||
│ Rewards: │
|
||||
│ 💰 50 gold │
|
||||
│ ⭐ 100 XP │
|
||||
│ │
|
||||
│ [Abandon Quest] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Week 9 of Phase 4 (Days 13-14)
|
||||
|
||||
**Day 13: Quest Data Models & Service**
|
||||
- Create Quest, QuestObjective, QuestReward, QuestTriggers dataclasses
|
||||
- Create QuestService (load from YAML, manage state)
|
||||
- Write 5-10 example quest YAML files (2 easy, 2 medium, 1 hard)
|
||||
- Unit tests for quest loading and progression logic
|
||||
|
||||
**Day 14: Quest Offering & Integration**
|
||||
- Implement quest offering logic (context-aware + location-based)
|
||||
- Integrate quest offering into story turn flow
|
||||
- Quest acceptance/completion API endpoints
|
||||
- Quest progress tracking during combat/story events
|
||||
- Quest UI components (tracker, offering modal, detail view)
|
||||
- Integration testing
|
||||
|
||||
---
|
||||
|
||||
## Testing Criteria
|
||||
|
||||
### Unit Tests
|
||||
- ✅ Quest loading from YAML
|
||||
- ✅ Quest.is_complete() logic
|
||||
- ✅ Quest.update_progress() logic
|
||||
- ✅ QuestTriggers.can_offer() filtering
|
||||
- ✅ QuestService.get_available_quests() filtering
|
||||
|
||||
### Integration Tests
|
||||
- ✅ Quest offered during story turn
|
||||
- ✅ Accept quest (add to active_quests)
|
||||
- ✅ Quest limit enforced (max 2 active)
|
||||
- ✅ Quest progress updates during combat
|
||||
- ✅ Complete quest and receive rewards
|
||||
- ✅ Level up from quest XP
|
||||
- ✅ Abandon quest
|
||||
|
||||
### Manual Testing
|
||||
- ✅ Full quest flow (offer → accept → progress → complete)
|
||||
- ✅ Multiple quests active simultaneously
|
||||
- ✅ Quest offering feels natural in narrative
|
||||
- ✅ Context-aware quest selection works
|
||||
- ✅ Location-based probabilities feel right
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ Quest data models implemented and tested
|
||||
- ✅ QuestService loads quests from YAML files
|
||||
- ✅ Quest offering logic integrated into story turns
|
||||
- ✅ Context-aware quest selection working
|
||||
- ✅ Location-based probability rolls functioning
|
||||
- ✅ Max 2 active quests enforced
|
||||
- ✅ Quest acceptance and tracking functional
|
||||
- ✅ Quest progress updates automatically
|
||||
- ✅ Quest completion and rewards granted
|
||||
- ✅ Quest UI components implemented
|
||||
- ✅ At least 5 example quests defined in YAML
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
### Phase 13+
|
||||
- **Quest chains**: Multi-quest storylines with prerequisites
|
||||
- **Repeatable quests**: Daily/weekly quests for ongoing rewards
|
||||
- **Dynamic quest generation**: AI creates custom quests on-the-fly
|
||||
- **Quest sharing**: Multiplayer party quests
|
||||
- **Quest journal**: Full quest log with completed quest history
|
||||
- **Quest dialogue trees**: Branching conversations with quest givers
|
||||
- **Time-limited quests**: Quests that expire after X turns
|
||||
- **Reputation system**: Quest completion affects faction standing
|
||||
- **Quest difficulty scaling**: Rewards scale with character level
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[STORY_PROGRESSION.md](STORY_PROGRESSION.md)** - Turn-based story gameplay system
|
||||
- **[DATA_MODELS.md](DATA_MODELS.md)** - Quest data model specifications
|
||||
- **[GAME_SYSTEMS.md](GAME_SYSTEMS.md)** - Combat integration with quests
|
||||
- **[API_REFERENCE.md](API_REFERENCE.md)** - Quest API endpoints
|
||||
- **[ROADMAP.md](ROADMAP.md)** - Phase 4 timeline
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Created:** November 16, 2025
|
||||
**Last Updated:** November 16, 2025
|
||||
435
api/docs/SESSION_MANAGEMENT.md
Normal file
435
api/docs/SESSION_MANAGEMENT.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Session Management
|
||||
|
||||
This document describes the game session system for Code of Conquest, covering both solo and multiplayer sessions.
|
||||
|
||||
**Last Updated:** November 22, 2025
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Game sessions track the state of gameplay including:
|
||||
- Current location and discovered locations
|
||||
- Conversation history between player and AI DM
|
||||
- Active quests (max 2)
|
||||
- World events
|
||||
- Combat encounters
|
||||
|
||||
Sessions support both **solo play** (single character) and **multiplayer** (party-based).
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### SessionType Enum
|
||||
|
||||
```python
|
||||
class SessionType(Enum):
|
||||
SOLO = "solo" # Single-player session
|
||||
MULTIPLAYER = "multiplayer" # Multi-player party session
|
||||
```
|
||||
|
||||
### LocationType Enum
|
||||
|
||||
```python
|
||||
class LocationType(Enum):
|
||||
TOWN = "town" # Town or city
|
||||
TAVERN = "tavern" # Tavern or inn
|
||||
WILDERNESS = "wilderness" # Outdoor wilderness areas
|
||||
DUNGEON = "dungeon" # Underground dungeons/caves
|
||||
RUINS = "ruins" # Ancient ruins
|
||||
LIBRARY = "library" # Library or archive
|
||||
SAFE_AREA = "safe_area" # Safe rest areas
|
||||
```
|
||||
|
||||
### GameState
|
||||
|
||||
Tracks current world state for a session:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class GameState:
|
||||
current_location: str = "Crossroads Village"
|
||||
location_type: LocationType = LocationType.TOWN
|
||||
discovered_locations: List[str] = []
|
||||
active_quests: List[str] = [] # Max 2
|
||||
world_events: List[Dict] = []
|
||||
```
|
||||
|
||||
### ConversationEntry
|
||||
|
||||
Single turn in the conversation history:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ConversationEntry:
|
||||
turn: int # Turn number (1-indexed)
|
||||
character_id: str # Acting character
|
||||
character_name: str # Character display name
|
||||
action: str # Player's action text
|
||||
dm_response: str # AI DM's response
|
||||
timestamp: str # ISO timestamp (auto-generated)
|
||||
combat_log: List[Dict] = [] # Combat actions if any
|
||||
quest_offered: Optional[Dict] = None # Quest offering info
|
||||
```
|
||||
|
||||
### GameSession
|
||||
|
||||
Main session object:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class GameSession:
|
||||
session_id: str
|
||||
session_type: SessionType = SessionType.SOLO
|
||||
solo_character_id: Optional[str] = None # For solo sessions
|
||||
user_id: str = ""
|
||||
party_member_ids: List[str] = [] # For multiplayer
|
||||
config: SessionConfig
|
||||
combat_encounter: Optional[CombatEncounter] = None
|
||||
conversation_history: List[ConversationEntry] = []
|
||||
game_state: GameState
|
||||
turn_order: List[str] = []
|
||||
current_turn: int = 0
|
||||
turn_number: int = 0
|
||||
created_at: str # ISO timestamp
|
||||
last_activity: str # ISO timestamp
|
||||
status: SessionStatus = SessionStatus.ACTIVE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SessionService
|
||||
|
||||
The `SessionService` class (`app/services/session_service.py`) provides all session operations.
|
||||
|
||||
### Initialization
|
||||
|
||||
```python
|
||||
from app.services.session_service import get_session_service
|
||||
|
||||
service = get_session_service()
|
||||
```
|
||||
|
||||
### Creating Sessions
|
||||
|
||||
#### Solo Session
|
||||
|
||||
```python
|
||||
session = service.create_solo_session(
|
||||
user_id="user_123",
|
||||
character_id="char_456",
|
||||
starting_location="Crossroads Village", # Optional
|
||||
starting_location_type=LocationType.TOWN # Optional
|
||||
)
|
||||
```
|
||||
|
||||
**Validations:**
|
||||
- User must own the character
|
||||
- User cannot exceed 5 active sessions
|
||||
|
||||
**Returns:** `GameSession` instance
|
||||
|
||||
**Raises:**
|
||||
- `CharacterNotFound` - Character doesn't exist or user doesn't own it
|
||||
- `SessionLimitExceeded` - User has 5+ active sessions
|
||||
|
||||
### Retrieving Sessions
|
||||
|
||||
#### Get Single Session
|
||||
|
||||
```python
|
||||
session = service.get_session(
|
||||
session_id="sess_789",
|
||||
user_id="user_123" # Optional, validates ownership
|
||||
)
|
||||
```
|
||||
|
||||
**Raises:** `SessionNotFound`
|
||||
|
||||
#### Get User's Sessions
|
||||
|
||||
```python
|
||||
sessions = service.get_user_sessions(
|
||||
user_id="user_123",
|
||||
active_only=True, # Default: True
|
||||
limit=25 # Default: 25
|
||||
)
|
||||
```
|
||||
|
||||
#### Count Sessions
|
||||
|
||||
```python
|
||||
count = service.count_user_sessions(
|
||||
user_id="user_123",
|
||||
active_only=True
|
||||
)
|
||||
```
|
||||
|
||||
### Updating Sessions
|
||||
|
||||
#### Direct Update
|
||||
|
||||
```python
|
||||
session.turn_number += 1
|
||||
session = service.update_session(session)
|
||||
```
|
||||
|
||||
#### End Session
|
||||
|
||||
```python
|
||||
session = service.end_session(
|
||||
session_id="sess_789",
|
||||
user_id="user_123"
|
||||
)
|
||||
# Sets status to COMPLETED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conversation History
|
||||
|
||||
### Adding Entries
|
||||
|
||||
```python
|
||||
session = service.add_conversation_entry(
|
||||
session_id="sess_789",
|
||||
character_id="char_456",
|
||||
character_name="Brave Hero",
|
||||
action="I explore the tavern",
|
||||
dm_response="You enter a smoky tavern filled with patrons...",
|
||||
combat_log=[], # Optional
|
||||
quest_offered={"quest_id": "q1", "name": "..."} # Optional
|
||||
)
|
||||
```
|
||||
|
||||
**Automatic behaviors:**
|
||||
- Increments `turn_number`
|
||||
- Adds timestamp
|
||||
- Updates `last_activity`
|
||||
|
||||
### Retrieving History
|
||||
|
||||
```python
|
||||
# Get all history (with pagination)
|
||||
history = service.get_conversation_history(
|
||||
session_id="sess_789",
|
||||
limit=20, # Optional
|
||||
offset=0 # Optional, from end
|
||||
)
|
||||
|
||||
# Get recent entries for AI context
|
||||
recent = service.get_recent_history(
|
||||
session_id="sess_789",
|
||||
num_turns=3 # Default: 3
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Game State Tracking
|
||||
|
||||
### Location Management
|
||||
|
||||
```python
|
||||
# Update current location
|
||||
session = service.update_location(
|
||||
session_id="sess_789",
|
||||
new_location="Dark Forest",
|
||||
location_type=LocationType.WILDERNESS
|
||||
)
|
||||
# Also adds to discovered_locations if new
|
||||
|
||||
# Add discovered location without traveling
|
||||
session = service.add_discovered_location(
|
||||
session_id="sess_789",
|
||||
location="Ancient Ruins"
|
||||
)
|
||||
```
|
||||
|
||||
### Quest Management
|
||||
|
||||
```python
|
||||
# Add active quest (max 2)
|
||||
session = service.add_active_quest(
|
||||
session_id="sess_789",
|
||||
quest_id="quest_goblin_cave"
|
||||
)
|
||||
|
||||
# Remove quest (on completion or abandonment)
|
||||
session = service.remove_active_quest(
|
||||
session_id="sess_789",
|
||||
quest_id="quest_goblin_cave"
|
||||
)
|
||||
```
|
||||
|
||||
**Raises:** `SessionValidationError` if adding 3rd quest
|
||||
|
||||
### World Events
|
||||
|
||||
```python
|
||||
session = service.add_world_event(
|
||||
session_id="sess_789",
|
||||
event={
|
||||
"type": "festival",
|
||||
"description": "A harvest festival begins in town"
|
||||
}
|
||||
)
|
||||
# Timestamp auto-added
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Limits
|
||||
|
||||
| Limit | Value | Notes |
|
||||
|-------|-------|-------|
|
||||
| Active sessions per user | 5 | End existing sessions to create new |
|
||||
| Active quests per session | 2 | Complete or abandon to accept new |
|
||||
| Conversation history | Unlimited | Consider archiving for very long sessions |
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
Sessions are stored in Appwrite `game_sessions` collection:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `$id` | string | Session ID (document ID) |
|
||||
| `userId` | string | Owner's user ID |
|
||||
| `sessionData` | string | JSON serialized GameSession |
|
||||
| `status` | string | "active", "completed", "timeout" |
|
||||
| `sessionType` | string | "solo" or "multiplayer" |
|
||||
|
||||
### Indexes
|
||||
|
||||
- `userId` - For user session queries
|
||||
- `status` - For active session filtering
|
||||
- `userId + status` - Composite for active user sessions
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Complete Solo Gameplay Flow
|
||||
|
||||
```python
|
||||
from app.services.session_service import get_session_service
|
||||
from app.models.enums import LocationType
|
||||
|
||||
service = get_session_service()
|
||||
|
||||
# 1. Create session
|
||||
session = service.create_solo_session(
|
||||
user_id="user_123",
|
||||
character_id="char_456"
|
||||
)
|
||||
|
||||
# 2. Player takes action, AI responds
|
||||
session = service.add_conversation_entry(
|
||||
session_id=session.session_id,
|
||||
character_id="char_456",
|
||||
character_name="Hero",
|
||||
action="I look around the village square",
|
||||
dm_response="The village square bustles with activity..."
|
||||
)
|
||||
|
||||
# 3. Player travels
|
||||
session = service.update_location(
|
||||
session_id=session.session_id,
|
||||
new_location="The Rusty Anchor Tavern",
|
||||
location_type=LocationType.TAVERN
|
||||
)
|
||||
|
||||
# 4. Quest offered and accepted
|
||||
session = service.add_active_quest(
|
||||
session_id=session.session_id,
|
||||
quest_id="quest_goblin_cave"
|
||||
)
|
||||
|
||||
# 5. End session
|
||||
session = service.end_session(
|
||||
session_id=session.session_id,
|
||||
user_id="user_123"
|
||||
)
|
||||
```
|
||||
|
||||
### Checking Session State
|
||||
|
||||
```python
|
||||
session = service.get_session("sess_789")
|
||||
|
||||
# Check session type
|
||||
if session.is_solo():
|
||||
char_id = session.solo_character_id
|
||||
else:
|
||||
char_id = session.get_current_character_id()
|
||||
|
||||
# Check current location
|
||||
location = session.game_state.current_location
|
||||
location_type = session.game_state.location_type
|
||||
|
||||
# Check active quests
|
||||
quests = session.game_state.active_quests
|
||||
can_accept_quest = len(quests) < 2
|
||||
|
||||
# Get recent context for AI
|
||||
recent = service.get_recent_history(session.session_id, num_turns=3)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Exception Classes
|
||||
|
||||
```python
|
||||
from app.services.session_service import (
|
||||
SessionNotFound,
|
||||
SessionLimitExceeded,
|
||||
SessionValidationError,
|
||||
)
|
||||
|
||||
try:
|
||||
session = service.get_session("invalid_id", "user_123")
|
||||
except SessionNotFound:
|
||||
# Session doesn't exist or user doesn't own it
|
||||
pass
|
||||
|
||||
try:
|
||||
service.create_solo_session(user_id, char_id)
|
||||
except SessionLimitExceeded:
|
||||
# User has 5+ active sessions
|
||||
pass
|
||||
|
||||
try:
|
||||
service.add_active_quest(session_id, "quest_3")
|
||||
except SessionValidationError:
|
||||
# Already have 2 active quests
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Run session tests:
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
pytest tests/test_session_model.py -v
|
||||
pytest tests/test_session_service.py -v
|
||||
|
||||
# Verification script (requires TEST_USER_ID and TEST_CHARACTER_ID in .env)
|
||||
python scripts/verify_session_persistence.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [DATA_MODELS.md](DATA_MODELS.md) - Character and item models
|
||||
- [STORY_PROGRESSION.md](STORY_PROGRESSION.md) - Story turn system
|
||||
- [QUEST_SYSTEM.md](QUEST_SYSTEM.md) - Quest mechanics
|
||||
- [API_REFERENCE.md](API_REFERENCE.md) - Session API endpoints
|
||||
985
api/docs/STORY_PROGRESSION.md
Normal file
985
api/docs/STORY_PROGRESSION.md
Normal file
@@ -0,0 +1,985 @@
|
||||
# Story Progression System
|
||||
|
||||
**Status:** Active
|
||||
**Phase:** 4 (AI Integration + Story Progression)
|
||||
**Timeline:** Week 8 of Phase 4 (Days 8-14)
|
||||
**Last Updated:** November 23, 2025
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Story Progression System is the core single-player gameplay loop where players interact with the AI Dungeon Master through turn-based actions. Unlike combat (which uses structured mechanics), story progression allows players to explore the world, gather information, travel, and engage with narrative content.
|
||||
|
||||
**Key Principles:**
|
||||
- **Solo sessions only** - Story progression is single-player focused
|
||||
- **Button-based actions** - Structured prompts reduce AI costs and improve response quality
|
||||
- **Tier-based features** - Free tier has basic actions, paid tiers unlock more options
|
||||
- **Turn-based gameplay** - Player action → AI response → state update → repeat
|
||||
- **Quest integration** - Quests are offered during story turns based on context and location
|
||||
- **Location-based world** - Structured world with regions, locations, and travel
|
||||
- **Persistent NPCs** - Characters with personalities, knowledge, and relationship tracking
|
||||
|
||||
---
|
||||
|
||||
## World Structure
|
||||
|
||||
### Regions and Locations
|
||||
|
||||
The game world is organized hierarchically:
|
||||
|
||||
```
|
||||
World
|
||||
└── Regions (e.g., Crossville Province)
|
||||
└── Locations (e.g., Crossville Village, The Rusty Anchor Tavern)
|
||||
└── NPCs (e.g., Grom Ironbeard, Elara the Herbalist)
|
||||
```
|
||||
|
||||
**Regions** group related locations together for organizational purposes. Each region has:
|
||||
- Regional lore and atmosphere
|
||||
- List of contained locations
|
||||
- Region-wide events (future feature)
|
||||
|
||||
**Locations** are the atomic units of the world map:
|
||||
- Each location has a type (town, tavern, wilderness, dungeon, etc.)
|
||||
- Locations contain NPCs who reside there
|
||||
- Travel connections define which locations can be reached from where
|
||||
- Some locations are "starting locations" where new characters spawn
|
||||
|
||||
### Location Discovery
|
||||
|
||||
Players don't know about all locations initially. Locations are discovered through:
|
||||
|
||||
1. **Starting location**: All new characters begin at a designated starting location (Crossville Village)
|
||||
2. **Travel exploration**: Adjacent locations listed in `discoverable_locations`
|
||||
3. **NPC conversations**: NPCs can reveal hidden locations via `reveals_locations`
|
||||
4. **Story events**: AI narrative can unlock new destinations
|
||||
|
||||
**Example Location Discovery Flow:**
|
||||
```
|
||||
1. Player starts in Crossville Village
|
||||
└── Discovered: [crossville_village]
|
||||
|
||||
2. Player explores village, finds paths to tavern and market
|
||||
└── Discovered: [crossville_village, crossville_tavern, crossville_market]
|
||||
|
||||
3. Player talks to tavern keeper, learns about old mines
|
||||
└── Discovered: [..., crossville_old_mines]
|
||||
```
|
||||
|
||||
### Travel System
|
||||
|
||||
Travel moves players between discovered locations:
|
||||
|
||||
1. **Check available destinations**: `GET /api/v1/travel/available?session_id={id}`
|
||||
2. **Travel to location**: `POST /api/v1/travel` with `session_id` and `location_id`
|
||||
3. **AI narration**: Travel triggers narrative description of journey and arrival
|
||||
4. **State update**: Player's `current_location_id` updated, NPCs at new location available
|
||||
|
||||
**Travel Restrictions:**
|
||||
- Can only travel to discovered locations
|
||||
- Some locations may require prerequisites (quest completion, level, etc.)
|
||||
- Travel may trigger random encounters (future feature)
|
||||
|
||||
---
|
||||
|
||||
## NPC Interaction System
|
||||
|
||||
### NPC Overview
|
||||
|
||||
NPCs are persistent characters with rich data for AI dialogue generation:
|
||||
|
||||
- **Personality**: Traits, speech patterns, and quirks
|
||||
- **Knowledge**: What they know (public) and secrets they may reveal
|
||||
- **Relationships**: How they feel about other NPCs
|
||||
- **Inventory**: Items for sale (if merchant)
|
||||
- **Quest connections**: Quests they give and locations they reveal
|
||||
|
||||
### Talking to NPCs
|
||||
|
||||
When players interact with NPCs:
|
||||
|
||||
1. **NPC selection**: Player chooses NPC from location's NPC list
|
||||
2. **Initial greeting or response**: Player either greets NPC or responds to previous dialogue
|
||||
3. **AI prompt built**: Includes NPC personality, knowledge, relationship state, and conversation history
|
||||
4. **Dialogue generated**: AI creates in-character response
|
||||
5. **State updated**: Interaction tracked, dialogue exchange saved, secrets potentially revealed
|
||||
|
||||
**Initial Greeting Flow:**
|
||||
```
|
||||
Player: Clicks "Greet" on Grom Ironbeard
|
||||
└── Topic: "greeting"
|
||||
|
||||
System builds prompt with:
|
||||
- NPC personality (gruff, honest, protective)
|
||||
- NPC speech style (short sentences, dwarven expressions)
|
||||
- Public knowledge (tavern history, knows travelers)
|
||||
- Conditional knowledge (if relationship >= 70, mention goblins)
|
||||
- Current relationship level (65 - friendly)
|
||||
|
||||
AI generates response as Grom:
|
||||
"Welcome to the Rusty Anchor! What'll it be?"
|
||||
```
|
||||
|
||||
**Bidirectional Conversation Flow:**
|
||||
|
||||
Players can respond to NPC dialogue to continue conversations:
|
||||
|
||||
```
|
||||
Player: Types "Have you heard any rumors lately?"
|
||||
└── player_response: "Have you heard any rumors lately?"
|
||||
|
||||
System builds prompt with:
|
||||
- All personality/knowledge context (same as above)
|
||||
- Previous dialogue history (last 3 exchanges for context)
|
||||
- Player's current response
|
||||
|
||||
AI generates continuation as Grom:
|
||||
"Aye, I've heard things. *leans in* Strange folk been coming
|
||||
through lately. Watch yerself on the roads, friend."
|
||||
|
||||
System saves exchange:
|
||||
- player_line: "Have you heard any rumors lately?"
|
||||
- npc_response: "Aye, I've heard things..."
|
||||
- timestamp: "2025-11-24T10:30:00Z"
|
||||
```
|
||||
|
||||
**Conversation History Display:**
|
||||
|
||||
The UI shows the full conversation history with each NPC:
|
||||
- Previous exchanges are displayed with reduced opacity
|
||||
- Current exchange is highlighted
|
||||
- Player can type new responses to continue the conversation
|
||||
- Last 10 exchanges per NPC are stored for context
|
||||
|
||||
### Relationship Tracking
|
||||
|
||||
Each character-NPC pair has an `NPCInteractionState`:
|
||||
|
||||
| Relationship Level | Status | Effects |
|
||||
|--------------------|--------|---------|
|
||||
| 0-20 | Hostile | NPC refuses to help, may report player |
|
||||
| 21-40 | Unfriendly | Minimal assistance, no secrets |
|
||||
| 41-60 | Neutral | Normal interaction |
|
||||
| 61-80 | Friendly | Better prices, more information |
|
||||
| 81-100 | Trusted | Full access to secrets, special quests |
|
||||
|
||||
**Relationship modifiers:**
|
||||
- Successful interactions: +1 to +5 relationship
|
||||
- Failed attempts: -1 to -5 relationship
|
||||
- Quest completion for NPC: +10 to +20 relationship
|
||||
- Helping NPC's friends: +5 relationship
|
||||
- Harming NPC's allies: -10 to -20 relationship
|
||||
|
||||
### Secret Knowledge Reveals
|
||||
|
||||
NPCs can hold secrets that unlock under conditions:
|
||||
|
||||
```yaml
|
||||
knowledge:
|
||||
secret:
|
||||
- "Saw strange lights in the forest last week"
|
||||
will_share_if:
|
||||
- condition: "relationship_level >= 70"
|
||||
reveals: "Has heard rumors of goblins gathering in the old mines"
|
||||
- condition: "interaction_count >= 5"
|
||||
reveals: "Knows about a hidden cave behind the waterfall"
|
||||
```
|
||||
|
||||
When a condition is met:
|
||||
1. Secret index added to `revealed_secrets` list
|
||||
2. Information included in AI prompt context
|
||||
3. NPC "remembers" sharing this in future conversations
|
||||
|
||||
### Location Reveals from NPCs
|
||||
|
||||
NPCs can unlock new locations for players:
|
||||
|
||||
```yaml
|
||||
reveals_locations:
|
||||
- "crossville_old_mines"
|
||||
- "hidden_waterfall_cave"
|
||||
```
|
||||
|
||||
When an NPC reveals a location:
|
||||
1. Location ID added to character's `discovered_locations`
|
||||
2. Location becomes available for travel
|
||||
3. AI narration describes how player learned about it
|
||||
|
||||
---
|
||||
|
||||
## Gameplay Loop
|
||||
|
||||
### Turn Structure
|
||||
|
||||
```
|
||||
1. Display current state (location, quests, conversation history)
|
||||
2. Present available actions (buttons based on tier + context)
|
||||
3. Player selects action (or enters custom text if Premium/Elite)
|
||||
4. Action sent to backend API
|
||||
5. AI processes action and generates narrative response
|
||||
6. Game state updated (location changes, quest progress, etc.)
|
||||
7. Quest offering check (context-aware + location-based)
|
||||
8. Response displayed to player
|
||||
9. Next turn begins
|
||||
```
|
||||
|
||||
### Turn Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Player Views Current State │
|
||||
│ - Location │
|
||||
│ - Active Quests │
|
||||
│ - Conversation History │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ System Presents Action Options │
|
||||
│ - Tier-based button selection │
|
||||
│ - Context-aware prompts │
|
||||
│ - Custom input (Premium/Elite) │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Player Selects Action │
|
||||
│ POST /api/v1/sessions/{id}/action │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Backend Processing │
|
||||
│ - Validate action │
|
||||
│ - Build AI prompt (context) │
|
||||
│ - Call AI API (tier-based model) │
|
||||
│ - Parse response │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Update Game State │
|
||||
│ - Location changes │
|
||||
│ - Quest progress │
|
||||
│ - Conversation history │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Quest Offering Check │
|
||||
│ - Context-aware analysis │
|
||||
│ - Location-based roll │
|
||||
│ - Max 2 active quests limit │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Return Response to Player │
|
||||
│ - AI narrative text │
|
||||
│ - State changes │
|
||||
│ - Quest offered (if any) │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Next Turn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Action System
|
||||
|
||||
### Action Categories
|
||||
|
||||
Actions are organized into three primary categories:
|
||||
|
||||
#### 1. Ask Questions
|
||||
Information-gathering actions that don't change location or trigger major events.
|
||||
|
||||
#### 2. Travel
|
||||
Movement actions that change the player's current location.
|
||||
|
||||
#### 3. Gather Information
|
||||
Social interaction actions that gather rumors, quests, and world knowledge.
|
||||
|
||||
### Tier-Based Action Availability
|
||||
|
||||
| Tier | Action Count | Free-Form Input | AI Model | Cost/Turn |
|
||||
|------|--------------|-----------------|----------|-----------|
|
||||
| **Free** | 4 basic actions | ❌ No | Replicate (free tier) | $0 |
|
||||
| **Basic** | 4 basic actions | ❌ No | Claude Haiku | ~$0.01 |
|
||||
| **Premium** | 7 actions | ✅ Yes (250 chars) | Claude Sonnet | ~$0.03 |
|
||||
| **Elite** | 10 actions | ✅ Yes (500 chars) | Claude Opus | ~$0.10 |
|
||||
|
||||
### Action Prompt Definitions
|
||||
|
||||
#### Free Tier Actions (4 total)
|
||||
|
||||
**Ask Questions:**
|
||||
1. **"What do I see around me?"** - Describe current location and visible surroundings
|
||||
2. **"Are there any dangers nearby?"** - Check for enemies, hazards, or threats
|
||||
|
||||
**Travel:**
|
||||
3. **"Travel to [dropdown: known towns]"** - Fast travel to discovered towns
|
||||
4. **"Explore the area"** - Random exploration in current region
|
||||
|
||||
#### Premium Tier Additional Actions (+3 more, 7 total)
|
||||
|
||||
**Ask Questions:**
|
||||
5. **"What do I remember about this place?"** - Recall location history (ties to Memory Thief origin)
|
||||
|
||||
**Gather Information:**
|
||||
6. **"Ask around the streets"** - Gather rumors from common folk (town/city only)
|
||||
7. **"Visit the local tavern"** - Gather information and hear tales (town/city only)
|
||||
|
||||
#### Elite Tier Additional Actions (+3 more, 10 total)
|
||||
|
||||
**Ask Questions:**
|
||||
8. **"Search for hidden secrets"** - Thorough investigation of area (may find loot or lore)
|
||||
|
||||
**Gather Information:**
|
||||
9. **"Seek out the town elder"** - Get official information and potential quests (town/city only)
|
||||
|
||||
**Travel:**
|
||||
10. **"Chart a course to unexplored lands"** - Discover new locations (wilderness only)
|
||||
|
||||
#### Premium/Elite: Custom Free-Form Input
|
||||
|
||||
**"Ask the DM a custom question"** (Premium/Elite only)
|
||||
- Premium: 250 character limit
|
||||
- Elite: 500 character limit
|
||||
- Examples: "I want to climb the tower and look for a vantage point", "Can I search the abandoned house for clues?"
|
||||
|
||||
---
|
||||
|
||||
## Action Prompt Data Model
|
||||
|
||||
### ActionPrompt Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ActionPrompt:
|
||||
"""Represents a button-based action prompt available to players."""
|
||||
|
||||
prompt_id: str # Unique identifier (e.g., "ask_surroundings")
|
||||
category: str # "ask", "travel", "gather"
|
||||
display_text: str # Button text shown to player
|
||||
description: str # Tooltip/help text
|
||||
tier_required: str # "free", "basic", "premium", "elite"
|
||||
context_filter: Optional[str] # "town", "wilderness", "any" (where action is available)
|
||||
dm_prompt_template: str # Jinja2 template for AI prompt
|
||||
|
||||
def is_available(self, user_tier: str, location_type: str) -> bool:
|
||||
"""Check if this action is available to the user."""
|
||||
# Check tier requirement
|
||||
tier_hierarchy = ["free", "basic", "premium", "elite"]
|
||||
if tier_hierarchy.index(user_tier) < tier_hierarchy.index(self.tier_required):
|
||||
return False
|
||||
|
||||
# Check context filter
|
||||
if self.context_filter and self.context_filter != "any":
|
||||
return location_type == self.context_filter
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### YAML Action Definitions
|
||||
|
||||
Actions will be defined in `/app/data/action_prompts.yaml`:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- prompt_id: "ask_surroundings"
|
||||
category: "ask"
|
||||
display_text: "What do I see around me?"
|
||||
description: "Get a description of your current surroundings"
|
||||
tier_required: "free"
|
||||
context_filter: "any"
|
||||
dm_prompt_template: |
|
||||
The player is currently in {{ location_name }}.
|
||||
Describe what they see, hear, and sense around them.
|
||||
Include atmosphere, notable features, and any immediate points of interest.
|
||||
|
||||
- prompt_id: "check_dangers"
|
||||
category: "ask"
|
||||
display_text: "Are there any dangers nearby?"
|
||||
description: "Check for threats, enemies, or hazards in the area"
|
||||
tier_required: "free"
|
||||
context_filter: "any"
|
||||
dm_prompt_template: |
|
||||
The player is in {{ location_name }} and wants to know about nearby dangers.
|
||||
Assess the area for threats: enemies, traps, environmental hazards.
|
||||
Be honest about danger level but maintain narrative tension.
|
||||
|
||||
- prompt_id: "travel_town"
|
||||
category: "travel"
|
||||
display_text: "Travel to..."
|
||||
description: "Fast travel to a known town or city"
|
||||
tier_required: "free"
|
||||
context_filter: "any"
|
||||
dm_prompt_template: |
|
||||
The player is traveling from {{ current_location }} to {{ destination }}.
|
||||
Describe a brief travel montage. Include:
|
||||
- Journey description (terrain, weather)
|
||||
- Any minor encounters or observations
|
||||
- Arrival at destination
|
||||
|
||||
- prompt_id: "explore_area"
|
||||
category: "travel"
|
||||
display_text: "Explore the area"
|
||||
description: "Wander and explore your current region"
|
||||
tier_required: "free"
|
||||
context_filter: "any"
|
||||
dm_prompt_template: |
|
||||
The player explores {{ location_name }}.
|
||||
Generate a random encounter or discovery:
|
||||
- 30% chance: Minor combat encounter
|
||||
- 30% chance: Interesting location/landmark
|
||||
- 20% chance: NPC interaction
|
||||
- 20% chance: Nothing significant
|
||||
|
||||
- prompt_id: "recall_memory"
|
||||
category: "ask"
|
||||
display_text: "What do I remember about this place?"
|
||||
description: "Recall memories or knowledge about current location"
|
||||
tier_required: "premium"
|
||||
context_filter: "any"
|
||||
dm_prompt_template: |
|
||||
The player tries to recall memories about {{ location_name }}.
|
||||
{% if character.origin == "memory_thief" %}
|
||||
The player has fragmented memories due to their amnesia.
|
||||
Provide vague, incomplete recollections that hint at a past here.
|
||||
{% else %}
|
||||
Provide relevant historical knowledge or personal memories.
|
||||
{% endif %}
|
||||
|
||||
- prompt_id: "gather_street_rumors"
|
||||
category: "gather"
|
||||
display_text: "Ask around the streets"
|
||||
description: "Talk to common folk and gather local rumors"
|
||||
tier_required: "premium"
|
||||
context_filter: "town"
|
||||
dm_prompt_template: |
|
||||
The player mingles with townsfolk in {{ location_name }}.
|
||||
Generate 2-3 local rumors or pieces of information:
|
||||
- Local events or concerns
|
||||
- Potential quest hooks
|
||||
- World lore or history
|
||||
|
||||
- prompt_id: "visit_tavern"
|
||||
category: "gather"
|
||||
display_text: "Visit the local tavern"
|
||||
description: "Gather information and hear tales at the tavern"
|
||||
tier_required: "premium"
|
||||
context_filter: "town"
|
||||
dm_prompt_template: |
|
||||
The player enters the tavern in {{ location_name }}.
|
||||
Describe the atmosphere and provide:
|
||||
- Overheard conversations
|
||||
- Rumors and tales
|
||||
- Potential quest opportunities
|
||||
- NPC interactions
|
||||
|
||||
- prompt_id: "search_secrets"
|
||||
category: "ask"
|
||||
display_text: "Search for hidden secrets"
|
||||
description: "Thoroughly investigate the area for hidden items or lore"
|
||||
tier_required: "elite"
|
||||
context_filter: "any"
|
||||
dm_prompt_template: |
|
||||
The player conducts a thorough search of {{ location_name }}.
|
||||
Roll for discovery (60% chance of finding something):
|
||||
- Hidden items or treasures
|
||||
- Secret passages or areas
|
||||
- Lore fragments or journals
|
||||
- Environmental storytelling clues
|
||||
|
||||
- prompt_id: "seek_elder"
|
||||
category: "gather"
|
||||
display_text: "Seek out the town elder"
|
||||
description: "Get official information and guidance from town leadership"
|
||||
tier_required: "elite"
|
||||
context_filter: "town"
|
||||
dm_prompt_template: |
|
||||
The player seeks audience with the town elder of {{ location_name }}.
|
||||
The elder provides:
|
||||
- Official town history and current situation
|
||||
- Important quests or tasks
|
||||
- Warnings about regional threats
|
||||
- Access to restricted information
|
||||
|
||||
- prompt_id: "chart_course"
|
||||
category: "travel"
|
||||
display_text: "Chart a course to unexplored lands"
|
||||
description: "Venture into unknown territory to discover new locations"
|
||||
tier_required: "elite"
|
||||
context_filter: "wilderness"
|
||||
dm_prompt_template: |
|
||||
The player ventures into unexplored territory from {{ location_name }}.
|
||||
Generate a new location discovery:
|
||||
- Name and type of location
|
||||
- Initial description
|
||||
- Potential dangers or opportunities
|
||||
- Add to player's discovered locations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Management
|
||||
|
||||
### Solo Session Creation
|
||||
|
||||
**Endpoint:** `POST /api/v1/sessions`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"character_id": "char_abc123",
|
||||
"session_type": "solo"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"session_id": "session_xyz789",
|
||||
"character_id": "char_abc123",
|
||||
"session_type": "solo",
|
||||
"current_location": "thornfield_plains",
|
||||
"turn_number": 0,
|
||||
"status": "active",
|
||||
"created_at": "2025-11-16T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Taking a Story Action
|
||||
|
||||
**Endpoint:** `POST /api/v1/sessions/{session_id}/action`
|
||||
|
||||
**Request Body (Button Action):**
|
||||
```json
|
||||
{
|
||||
"action_type": "prompt",
|
||||
"prompt_id": "ask_surroundings"
|
||||
}
|
||||
```
|
||||
|
||||
**Request Body (Custom Free-Form - Premium/Elite Only):**
|
||||
```json
|
||||
{
|
||||
"action_type": "custom",
|
||||
"custom_text": "I want to climb the old tower and scout the horizon"
|
||||
}
|
||||
```
|
||||
|
||||
**Request Body (Ask DM - Out-of-Character Question):**
|
||||
```json
|
||||
{
|
||||
"action_type": "ask_dm",
|
||||
"question": "Is there anyone around who might see me steal this item?"
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Ask DM questions are informational only. They don't advance the story, increment the turn number, or cause game state changes. They use a separate rate limit from regular actions.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"session_id": "session_xyz789",
|
||||
"turn_number": 1,
|
||||
"action_taken": "What do I see around me?",
|
||||
"dm_response": "You stand in the windswept Thornfield Plains, tall grasses swaying in the breeze. To the north, you can make out the silhouette of a crumbling watchtower against the grey sky. The air smells of rain and distant smoke. A worn path leads east toward what might be a settlement.",
|
||||
"state_changes": {
|
||||
"discovered_locations": ["old_watchtower"]
|
||||
},
|
||||
"quest_offered": null,
|
||||
"ai_cost": 0.025,
|
||||
"timestamp": "2025-11-16T10:01:23Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Getting Session State
|
||||
|
||||
**Endpoint:** `GET /api/v1/sessions/{session_id}`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"session_id": "session_xyz789",
|
||||
"character_id": "char_abc123",
|
||||
"session_type": "solo",
|
||||
"current_location": "thornfield_plains",
|
||||
"location_type": "wilderness",
|
||||
"turn_number": 5,
|
||||
"status": "active",
|
||||
"active_quests": ["quest_001"],
|
||||
"discovered_locations": ["thornfield_plains", "old_watchtower", "riverwatch"],
|
||||
"conversation_history": [
|
||||
{
|
||||
"turn": 1,
|
||||
"action": "What do I see around me?",
|
||||
"dm_response": "You stand in the windswept Thornfield Plains..."
|
||||
}
|
||||
],
|
||||
"available_actions": [
|
||||
{
|
||||
"prompt_id": "ask_surroundings",
|
||||
"display_text": "What do I see around me?",
|
||||
"category": "ask"
|
||||
},
|
||||
{
|
||||
"prompt_id": "check_dangers",
|
||||
"display_text": "Are there any dangers nearby?",
|
||||
"category": "ask"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI Prompt Engineering
|
||||
|
||||
### Context Building
|
||||
|
||||
Each player action requires building a comprehensive prompt for the AI Dungeon Master:
|
||||
|
||||
```python
|
||||
def build_dm_prompt(session: GameSession, character: Character, action: ActionPrompt, custom_text: Optional[str] = None) -> str:
|
||||
"""Build the AI prompt with full context."""
|
||||
|
||||
context = {
|
||||
# Character info
|
||||
"character_name": character.name,
|
||||
"character_class": character.player_class.name,
|
||||
"character_level": character.level,
|
||||
"character_origin": character.origin,
|
||||
|
||||
# Location info
|
||||
"location_name": session.game_state.current_location,
|
||||
"location_type": get_location_type(session.game_state.current_location),
|
||||
"discovered_locations": session.game_state.discovered_locations,
|
||||
|
||||
# Quest info
|
||||
"active_quests": get_quest_details(session.game_state.active_quests),
|
||||
|
||||
# Recent history (last 3 turns)
|
||||
"recent_history": session.conversation_history[-3:],
|
||||
|
||||
# Player action
|
||||
"player_action": custom_text or action.display_text,
|
||||
}
|
||||
|
||||
# Render base prompt template
|
||||
if custom_text:
|
||||
prompt = render_custom_action_prompt(context, custom_text)
|
||||
else:
|
||||
prompt = render_template(action.dm_prompt_template, context)
|
||||
|
||||
return prompt
|
||||
```
|
||||
|
||||
### AI Model Selection by Tier
|
||||
|
||||
```python
|
||||
def get_ai_model_for_tier(user_tier: str) -> str:
|
||||
"""Select AI model based on user subscription tier."""
|
||||
model_map = {
|
||||
"free": "replicate/llama-3-8b", # Free tier model
|
||||
"basic": "claude-haiku", # Fast, cheap
|
||||
"premium": "claude-sonnet", # Balanced
|
||||
"elite": "claude-opus" # Best quality
|
||||
}
|
||||
return model_map.get(user_tier, "replicate/llama-3-8b")
|
||||
```
|
||||
|
||||
### Response Parsing and Item Extraction
|
||||
|
||||
AI responses include both narrative text and structured game actions. The system automatically parses responses to extract items, gold, and experience to apply to the player's character.
|
||||
|
||||
**Response Format:**
|
||||
|
||||
The AI returns narrative followed by a structured `---GAME_ACTIONS---` block:
|
||||
|
||||
```
|
||||
The kind vendor smiles and presses a few items into your hands...
|
||||
|
||||
---GAME_ACTIONS---
|
||||
{
|
||||
"items_given": [
|
||||
{"name": "Stale Bread", "type": "consumable", "description": "A half-loaf of bread", "value": 1},
|
||||
{"item_id": "health_potion"}
|
||||
],
|
||||
"items_taken": [],
|
||||
"gold_given": 5,
|
||||
"gold_taken": 0,
|
||||
"experience_given": 10,
|
||||
"quest_offered": null,
|
||||
"quest_completed": null,
|
||||
"location_change": null
|
||||
}
|
||||
```
|
||||
|
||||
**Item Grant Types:**
|
||||
|
||||
1. **Existing Items** (by `item_id`): References items from the game data registry
|
||||
2. **Generic Items** (by name/type/description): Creates simple mundane items
|
||||
|
||||
**Item Validation:**
|
||||
|
||||
All AI-granted items are validated before being added to inventory:
|
||||
|
||||
- **Level requirements**: Items requiring higher level than character are rejected
|
||||
- **Class restrictions**: Class-specific items validated against character class
|
||||
- **Validation logging**: Failed items logged for review but don't fail the request
|
||||
|
||||
**Processing Flow:**
|
||||
|
||||
```python
|
||||
# 1. Parse AI response
|
||||
parsed_response = parse_ai_response(ai_response.narrative)
|
||||
|
||||
# 2. Validate and resolve items
|
||||
for item_grant in parsed_response.game_changes.items_given:
|
||||
item, error = validator.validate_and_resolve_item(item_grant, character)
|
||||
if item:
|
||||
character.add_item(item)
|
||||
|
||||
# 3. Apply gold/experience
|
||||
character.add_gold(parsed_response.game_changes.gold_given)
|
||||
character.add_experience(parsed_response.game_changes.experience_given)
|
||||
|
||||
# 4. Save to database
|
||||
```
|
||||
|
||||
**Generic Item Templates:**
|
||||
|
||||
Common mundane items have predefined templates in `/app/data/generic_items.yaml`:
|
||||
- Light sources: torch, lantern, candle
|
||||
- Food: bread, cheese, rations, ale
|
||||
- Tools: rope, flint, bedroll, crowbar
|
||||
- Supplies: ink, parchment, bandages
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Design
|
||||
|
||||
### Story Gameplay Screen Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Code of Conquest [Char] [Logout] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📍 Current Location: Thornfield Plains │
|
||||
│ 🎯 Active Quests: 1 │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Conversation History │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Turn 1 │ │
|
||||
│ │ You: What do I see around me? │ │
|
||||
│ │ │ │
|
||||
│ │ DM: You stand in the windswept Thornfield │ │
|
||||
│ │ Plains, tall grasses swaying in the breeze. │ │
|
||||
│ │ To the north, you can make out the │ │
|
||||
│ │ silhouette of a crumbling watchtower... │ │
|
||||
│ │ │ │
|
||||
│ │ Turn 2 │ │
|
||||
│ │ You: Explore the area │ │
|
||||
│ │ │ │
|
||||
│ │ DM: As you wander through the plains, you │ │
|
||||
│ │ stumble upon fresh tracks leading north... │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Take Action │
|
||||
│ │
|
||||
│ Ask Questions: │
|
||||
│ [What do I see?] [Any dangers?] │
|
||||
│ [What do I remember?] 🔒Premium │
|
||||
│ │
|
||||
│ Travel: │
|
||||
│ [Travel to ▼] [Explore area] │
|
||||
│ │
|
||||
│ Gather Information: │
|
||||
│ [Ask around streets] 🔒Premium │
|
||||
│ [Visit tavern] 🔒Premium │
|
||||
│ │
|
||||
│ Custom (Premium/Elite): │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ I want to... │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ [Ask the DM] (250 chars) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Action Button States
|
||||
|
||||
- **Available**: Full color, clickable
|
||||
- **Tier-locked**: Greyed out with 🔒 icon and "Premium" or "Elite" badge
|
||||
- **Context-locked**: Hidden (e.g., "Visit tavern" not shown in wilderness)
|
||||
- **Loading**: Disabled with spinner during AI processing
|
||||
|
||||
### Conversation History Display
|
||||
|
||||
- Scrollable container with newest at bottom
|
||||
- Turn numbers clearly visible
|
||||
- Player actions in different style from DM responses
|
||||
- Quest notifications highlighted
|
||||
- Location changes noted
|
||||
- Timestamps optional (show on hover)
|
||||
|
||||
---
|
||||
|
||||
## Cost Management
|
||||
|
||||
### Per-Turn Cost Estimates
|
||||
|
||||
| Tier | AI Model | Est. Tokens | Cost/Turn | Daily Limit (Turns) |
|
||||
|------|----------|-------------|-----------|---------------------|
|
||||
| Free | Replicate Llama-3 8B | ~500 | $0 | Unlimited |
|
||||
| Basic | Claude Haiku | ~800 | ~$0.01 | 200 turns/day |
|
||||
| Premium | Claude Sonnet | ~1200 | ~$0.03 | 100 turns/day |
|
||||
| Elite | Claude Opus | ~1500 | ~$0.10 | 50 turns/day |
|
||||
|
||||
### Cost Control Measures
|
||||
|
||||
1. **Token limits**: Cap AI responses at 500 tokens (narrative should be concise)
|
||||
2. **Daily turn limits**: Prevent abuse and runaway costs
|
||||
3. **Prompt caching**: Cache location descriptions, character context (Phase 4B)
|
||||
4. **Model selection**: Free tier uses free model, paid tiers get better quality
|
||||
5. **Monitoring**: Track per-user AI spending daily
|
||||
|
||||
---
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Week 8 of Phase 4 (Days 8-14)
|
||||
|
||||
**Day 8: Action Prompt System**
|
||||
- Create ActionPrompt dataclass
|
||||
- Create ActionPromptLoader (YAML-based)
|
||||
- Define all 10 action prompts in `action_prompts.yaml`
|
||||
- Write unit tests for action availability logic
|
||||
|
||||
**Day 9: Session Service**
|
||||
- Create SessionService (create solo sessions, manage state)
|
||||
- Session creation logic
|
||||
- Session state retrieval
|
||||
- Turn advancement logic
|
||||
- Database operations
|
||||
|
||||
**Day 10: Story Progression API**
|
||||
- Implement story action endpoints:
|
||||
- `POST /api/v1/sessions` - Create solo session
|
||||
- `GET /api/v1/sessions/{id}` - Get session state
|
||||
- `POST /api/v1/sessions/{id}/action` - Take action
|
||||
- `GET /api/v1/sessions/{id}/history` - Get conversation history
|
||||
- Validate actions based on tier and context
|
||||
- Integrate with AI service (from Week 7)
|
||||
|
||||
**Day 11-12: Story Progression UI**
|
||||
- Create `templates/game/story.html` - Main story gameplay screen
|
||||
- Action button rendering (tier-based, context-aware)
|
||||
- Conversation history display (scrollable, formatted)
|
||||
- HTMX integration for real-time turn updates
|
||||
- Loading states during AI processing
|
||||
|
||||
**Day 13-14: Integration Testing**
|
||||
- Test full story turn flow
|
||||
- Test tier restrictions
|
||||
- Test action context filtering
|
||||
- Test AI prompt building and response parsing
|
||||
- Test session state persistence
|
||||
- Performance testing (response times, cost tracking)
|
||||
|
||||
---
|
||||
|
||||
## Testing Criteria
|
||||
|
||||
### Unit Tests
|
||||
- ✅ ActionPrompt.is_available() logic
|
||||
- ✅ ActionPromptLoader loads all prompts correctly
|
||||
- ✅ Tier hierarchy validation
|
||||
- ✅ Context filtering (town vs wilderness)
|
||||
|
||||
### Integration Tests
|
||||
- ✅ Create solo session
|
||||
- ✅ Take action (button-based)
|
||||
- ✅ Take action (custom text, Premium/Elite)
|
||||
- ✅ Verify tier restrictions enforced
|
||||
- ✅ Verify context filtering works
|
||||
- ✅ Verify conversation history persists
|
||||
- ✅ Verify AI cost tracking
|
||||
- ✅ Verify daily turn limits
|
||||
|
||||
### Manual Testing
|
||||
- ✅ Full story turn flow (10+ turns)
|
||||
- ✅ All action types tested
|
||||
- ✅ Custom text input (Premium/Elite)
|
||||
- ✅ UI responsiveness
|
||||
- ✅ Conversation history display
|
||||
- ✅ Quest offering during turns (tested in Quest System phase)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ All 10 action prompts defined and loadable from YAML
|
||||
- ✅ Action availability logic working (tier + context)
|
||||
- ✅ Solo session creation and management functional
|
||||
- ✅ Story action API endpoints operational
|
||||
- ✅ AI prompt building with full context
|
||||
- ✅ AI responses parsed and state updated correctly
|
||||
- ✅ Story gameplay UI functional with HTMX
|
||||
- ✅ Tier restrictions enforced (Free vs Premium vs Elite)
|
||||
- ✅ Custom text input working for Premium/Elite tiers
|
||||
- ✅ Conversation history persisted and displayed
|
||||
- ✅ Cost tracking and daily limits enforced
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
### Phase 13+
|
||||
- **Dynamic action generation**: AI suggests context-specific actions
|
||||
- **Voice narration**: Text-to-speech for DM responses (Elite tier)
|
||||
- **Branching narratives**: Major story choices with consequences
|
||||
- **Location memory**: AI remembers previous visits and descriptions
|
||||
- ~~**NPC persistence**: Recurring NPCs with memory of past interactions~~ ✅ Implemented
|
||||
- **Session sharing**: Export/share story sessions as Markdown
|
||||
- **Illustrations**: AI-generated scene images (Elite tier)
|
||||
- **Random encounters**: Travel between locations may trigger events
|
||||
- **NPC schedules**: NPCs move between locations based on time of day
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[QUEST_SYSTEM.md](QUEST_SYSTEM.md)** - Quest offering and tracking during story turns
|
||||
- **[GAME_SYSTEMS.md](GAME_SYSTEMS.md)** - Combat system (separate turn-based gameplay)
|
||||
- **[DATA_MODELS.md](DATA_MODELS.md)** - GameSession, ConversationEntry, ActionPrompt, Location, NPC models
|
||||
- **[API_REFERENCE.md](API_REFERENCE.md)** - Full API endpoint documentation (includes Travel and NPC APIs)
|
||||
- **[ROADMAP.md](ROADMAP.md)** - Phase 4 timeline and milestones
|
||||
|
||||
### Data Files
|
||||
- `/app/data/regions/crossville.yaml` - Crossville region locations
|
||||
- `/app/data/npcs/crossville_npcs.yaml` - NPCs in Crossville region
|
||||
- `/app/data/action_prompts.yaml` - Player action definitions
|
||||
|
||||
### Services
|
||||
- `LocationLoader` - Loads and caches location/region data
|
||||
- `NPCLoader` - Loads and caches NPC definitions
|
||||
- `SessionService` - Manages game sessions and state
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.2
|
||||
**Created:** November 16, 2025
|
||||
**Last Updated:** November 24, 2025
|
||||
614
api/docs/USAGE_TRACKING.md
Normal file
614
api/docs/USAGE_TRACKING.md
Normal file
@@ -0,0 +1,614 @@
|
||||
# Usage Tracking & Cost Controls
|
||||
|
||||
## Overview
|
||||
|
||||
Code of Conquest implements comprehensive usage tracking and cost controls for AI operations. This ensures sustainable costs, fair usage across tiers, and visibility into system usage patterns.
|
||||
|
||||
**Key Components:**
|
||||
- **UsageTrackingService** - Logs all AI usage and calculates costs
|
||||
- **RateLimiterService** - Enforces tier-based daily limits
|
||||
- **AIUsageLog** - Data model for usage events
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ AI Task Jobs │
|
||||
├─────────────────────┤
|
||||
│ UsageTrackingService│ ← Logs usage, calculates costs
|
||||
├─────────────────────┤
|
||||
│ RateLimiterService │ ← Enforces limits before processing
|
||||
├─────────────────────┤
|
||||
│ Redis + Appwrite │ ← Storage layer
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Tracking Service
|
||||
|
||||
**File:** `app/services/usage_tracking_service.py`
|
||||
|
||||
### Initialization
|
||||
|
||||
```python
|
||||
from app.services.usage_tracking_service import UsageTrackingService
|
||||
|
||||
tracker = UsageTrackingService()
|
||||
```
|
||||
|
||||
**Required Environment Variables:**
|
||||
```bash
|
||||
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
APPWRITE_PROJECT_ID=your-project-id
|
||||
APPWRITE_API_KEY=your-api-key
|
||||
APPWRITE_DATABASE_ID=main
|
||||
```
|
||||
|
||||
### Logging Usage
|
||||
|
||||
```python
|
||||
from app.models.ai_usage import TaskType
|
||||
|
||||
# Log a usage event
|
||||
usage_log = tracker.log_usage(
|
||||
user_id="user_123",
|
||||
model="anthropic/claude-3.5-sonnet",
|
||||
tokens_input=150,
|
||||
tokens_output=450,
|
||||
task_type=TaskType.STORY_PROGRESSION,
|
||||
session_id="sess_789",
|
||||
character_id="char_456",
|
||||
request_duration_ms=2500,
|
||||
success=True
|
||||
)
|
||||
|
||||
print(f"Log ID: {usage_log.log_id}")
|
||||
print(f"Cost: ${usage_log.estimated_cost:.6f}")
|
||||
```
|
||||
|
||||
### Querying Usage
|
||||
|
||||
**Daily Usage:**
|
||||
```python
|
||||
from datetime import date
|
||||
|
||||
# Get today's usage
|
||||
usage = tracker.get_daily_usage("user_123", date.today())
|
||||
|
||||
print(f"Requests: {usage.total_requests}")
|
||||
print(f"Tokens: {usage.total_tokens}")
|
||||
print(f"Input tokens: {usage.total_input_tokens}")
|
||||
print(f"Output tokens: {usage.total_output_tokens}")
|
||||
print(f"Cost: ${usage.estimated_cost:.4f}")
|
||||
print(f"By task: {usage.requests_by_task}")
|
||||
# {"story_progression": 10, "combat_narration": 3, ...}
|
||||
```
|
||||
|
||||
**Monthly Cost:**
|
||||
```python
|
||||
# Get November 2025 cost
|
||||
monthly = tracker.get_monthly_cost("user_123", 2025, 11)
|
||||
|
||||
print(f"Monthly requests: {monthly.total_requests}")
|
||||
print(f"Monthly tokens: {monthly.total_tokens}")
|
||||
print(f"Monthly cost: ${monthly.estimated_cost:.2f}")
|
||||
```
|
||||
|
||||
**Admin Monitoring:**
|
||||
```python
|
||||
# Get total platform cost for a day
|
||||
total_cost = tracker.get_total_daily_cost(date.today())
|
||||
print(f"Platform daily cost: ${total_cost:.2f}")
|
||||
|
||||
# Get user request count for rate limiting
|
||||
count = tracker.get_user_request_count_today("user_123")
|
||||
```
|
||||
|
||||
### Cost Estimation
|
||||
|
||||
**Static Methods (no instance needed):**
|
||||
```python
|
||||
from app.services.usage_tracking_service import UsageTrackingService
|
||||
|
||||
# Estimate cost for specific request
|
||||
cost = UsageTrackingService.estimate_cost_for_model(
|
||||
model="anthropic/claude-3.5-sonnet",
|
||||
tokens_input=100,
|
||||
tokens_output=400
|
||||
)
|
||||
print(f"Estimated: ${cost:.6f}")
|
||||
|
||||
# Get model pricing
|
||||
info = UsageTrackingService.get_model_cost_info("anthropic/claude-3.5-sonnet")
|
||||
print(f"Input: ${info['input']}/1K tokens")
|
||||
print(f"Output: ${info['output']}/1K tokens")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Pricing
|
||||
|
||||
Costs per 1,000 tokens (USD):
|
||||
|
||||
| Model | Input | Output | Tier |
|
||||
|-------|-------|--------|------|
|
||||
| `meta/meta-llama-3-8b-instruct` | $0.0001 | $0.0001 | Free |
|
||||
| `meta/meta-llama-3-70b-instruct` | $0.0006 | $0.0006 | - |
|
||||
| `anthropic/claude-3.5-haiku` | $0.001 | $0.005 | Basic |
|
||||
| `anthropic/claude-3.5-sonnet` | $0.003 | $0.015 | Premium |
|
||||
| `anthropic/claude-4.5-sonnet` | $0.003 | $0.015 | Elite |
|
||||
| `anthropic/claude-3-opus` | $0.015 | $0.075 | - |
|
||||
|
||||
**Default cost for unknown models:** $0.001 input, $0.005 output per 1K tokens
|
||||
|
||||
---
|
||||
|
||||
## Token Estimation
|
||||
|
||||
Since the Replicate API doesn't return exact token counts, tokens are estimated based on text length.
|
||||
|
||||
### Estimation Formula
|
||||
|
||||
```python
|
||||
# Approximate 4 characters per token
|
||||
tokens = len(text) // 4
|
||||
```
|
||||
|
||||
### How Tokens Are Calculated
|
||||
|
||||
**Input Tokens:**
|
||||
- Calculated from the full prompt sent to the AI
|
||||
- Includes: user prompt + system prompt
|
||||
- Estimated at: `len(prompt + system_prompt) // 4`
|
||||
|
||||
**Output Tokens:**
|
||||
- Calculated from the AI's response text
|
||||
- Estimated at: `len(response_text) // 4`
|
||||
|
||||
### ReplicateResponse Structure
|
||||
|
||||
The Replicate client returns both input and output token estimates:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ReplicateResponse:
|
||||
text: str
|
||||
tokens_used: int # Total (input + output)
|
||||
tokens_input: int # Estimated input tokens
|
||||
tokens_output: int # Estimated output tokens
|
||||
model: str
|
||||
generation_time: float
|
||||
```
|
||||
|
||||
### Example Token Counts
|
||||
|
||||
| Content | Characters | Estimated Tokens |
|
||||
|---------|------------|------------------|
|
||||
| Short prompt | 400 chars | ~100 tokens |
|
||||
| Full DM prompt | 4,000 chars | ~1,000 tokens |
|
||||
| Short response | 200 chars | ~50 tokens |
|
||||
| Full narrative | 800 chars | ~200 tokens |
|
||||
|
||||
### Accuracy Notes
|
||||
|
||||
- Estimation is approximate (~75-80% accurate)
|
||||
- Real tokenization varies by model
|
||||
- Better to over-estimate for cost budgeting
|
||||
- Logs use estimates; billing reconciliation may differ
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
**File:** `app/models/ai_usage.py`
|
||||
|
||||
### AIUsageLog
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AIUsageLog:
|
||||
log_id: str # Unique identifier
|
||||
user_id: str # User who made request
|
||||
timestamp: datetime # When request was made
|
||||
model: str # Model identifier
|
||||
tokens_input: int # Input/prompt tokens
|
||||
tokens_output: int # Output/response tokens
|
||||
tokens_total: int # Total tokens
|
||||
estimated_cost: float # Cost in USD
|
||||
task_type: TaskType # Type of task
|
||||
session_id: Optional[str] # Game session
|
||||
character_id: Optional[str] # Character
|
||||
request_duration_ms: int # Duration
|
||||
success: bool # Success status
|
||||
error_message: Optional[str] # Error if failed
|
||||
```
|
||||
|
||||
### TaskType Enum
|
||||
|
||||
```python
|
||||
class TaskType(str, Enum):
|
||||
STORY_PROGRESSION = "story_progression"
|
||||
COMBAT_NARRATION = "combat_narration"
|
||||
QUEST_SELECTION = "quest_selection"
|
||||
NPC_DIALOGUE = "npc_dialogue"
|
||||
GENERAL = "general"
|
||||
```
|
||||
|
||||
### Summary Objects
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class DailyUsageSummary:
|
||||
date: date
|
||||
user_id: str
|
||||
total_requests: int
|
||||
total_tokens: int
|
||||
total_input_tokens: int
|
||||
total_output_tokens: int
|
||||
estimated_cost: float
|
||||
requests_by_task: Dict[str, int]
|
||||
|
||||
@dataclass
|
||||
class MonthlyUsageSummary:
|
||||
year: int
|
||||
month: int
|
||||
user_id: str
|
||||
total_requests: int
|
||||
total_tokens: int
|
||||
estimated_cost: float
|
||||
daily_breakdown: list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiter Service
|
||||
|
||||
**File:** `app/services/rate_limiter_service.py`
|
||||
|
||||
### Daily Turn Limits
|
||||
|
||||
| Tier | Limit | Cost Level |
|
||||
|------|-------|------------|
|
||||
| FREE | 20 turns/day | Zero |
|
||||
| BASIC | 50 turns/day | Low |
|
||||
| PREMIUM | 100 turns/day | Medium |
|
||||
| ELITE | 200 turns/day | High |
|
||||
|
||||
Counters reset at midnight UTC.
|
||||
|
||||
### Custom Action Limits
|
||||
|
||||
Free-text actions (beyond preset buttons) have additional limits per tier:
|
||||
|
||||
| Tier | Custom Actions/Day | Character Limit |
|
||||
|------|-------------------|-----------------|
|
||||
| FREE | 10 | 150 chars |
|
||||
| BASIC | 50 | 300 chars |
|
||||
| PREMIUM | Unlimited | 500 chars |
|
||||
| ELITE | Unlimited | 500 chars |
|
||||
|
||||
**Configuration:** These values are defined in `config/*.yaml` under `rate_limiting.tiers`:
|
||||
```yaml
|
||||
tiers:
|
||||
free:
|
||||
custom_actions_per_day: 10
|
||||
custom_action_char_limit: 150
|
||||
```
|
||||
|
||||
**Access in code:**
|
||||
```python
|
||||
from app.config import get_config
|
||||
|
||||
config = get_config()
|
||||
tier_config = config.rate_limiting.tiers['free']
|
||||
print(tier_config.custom_actions_per_day) # 10
|
||||
print(tier_config.custom_action_char_limit) # 150
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
limiter = RateLimiterService()
|
||||
|
||||
# Check and increment (typical flow)
|
||||
try:
|
||||
limiter.check_rate_limit("user_123", UserTier.PREMIUM)
|
||||
# Process AI request...
|
||||
limiter.increment_usage("user_123")
|
||||
except RateLimitExceeded as e:
|
||||
print(f"Limit reached: {e.current_usage}/{e.limit}")
|
||||
print(f"Resets at: {e.reset_time}")
|
||||
```
|
||||
|
||||
### Query Methods
|
||||
|
||||
```python
|
||||
# Get current usage
|
||||
current = limiter.get_current_usage("user_123")
|
||||
|
||||
# Get remaining turns
|
||||
remaining = limiter.get_remaining_turns("user_123", UserTier.PREMIUM)
|
||||
print(f"Remaining: {remaining} turns")
|
||||
|
||||
# Get comprehensive info
|
||||
info = limiter.get_usage_info("user_123", UserTier.PREMIUM)
|
||||
# {
|
||||
# "user_id": "user_123",
|
||||
# "user_tier": "premium",
|
||||
# "current_usage": 45,
|
||||
# "daily_limit": 100,
|
||||
# "remaining": 55,
|
||||
# "reset_time": "2025-11-22T00:00:00+00:00",
|
||||
# "is_limited": False
|
||||
# }
|
||||
|
||||
# Get limit for tier
|
||||
limit = limiter.get_limit_for_tier(UserTier.ELITE) # 200
|
||||
```
|
||||
|
||||
### Admin Functions
|
||||
|
||||
```python
|
||||
# Reset user's daily counter (testing/admin)
|
||||
limiter.reset_usage("user_123")
|
||||
```
|
||||
|
||||
### RateLimitExceeded Exception
|
||||
|
||||
```python
|
||||
class RateLimitExceeded(Exception):
|
||||
user_id: str
|
||||
user_tier: UserTier
|
||||
limit: int
|
||||
current_usage: int
|
||||
reset_time: datetime
|
||||
```
|
||||
|
||||
Provides all information needed for user-friendly error messages.
|
||||
|
||||
---
|
||||
|
||||
## Integration Pattern
|
||||
|
||||
### In AI Task Jobs
|
||||
|
||||
```python
|
||||
from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded
|
||||
from app.services.usage_tracking_service import UsageTrackingService
|
||||
from app.ai.narrative_generator import NarrativeGenerator
|
||||
from app.models.ai_usage import TaskType
|
||||
|
||||
def process_ai_request(user_id: str, user_tier: UserTier, action: str, ...):
|
||||
limiter = RateLimiterService()
|
||||
tracker = UsageTrackingService()
|
||||
generator = NarrativeGenerator()
|
||||
|
||||
# 1. Check rate limit BEFORE processing
|
||||
try:
|
||||
limiter.check_rate_limit(user_id, user_tier)
|
||||
except RateLimitExceeded as e:
|
||||
return {
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": f"Daily limit reached ({e.limit} turns). Resets at {e.reset_time}",
|
||||
"remaining": 0,
|
||||
"reset_time": e.reset_time.isoformat()
|
||||
}
|
||||
|
||||
# 2. Generate AI response
|
||||
start_time = time.time()
|
||||
response = generator.generate_story_response(...)
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 3. Log usage (tokens are estimated in ReplicateClient)
|
||||
tracker.log_usage(
|
||||
user_id=user_id,
|
||||
model=response.model,
|
||||
tokens_input=response.tokens_input, # From prompt length
|
||||
tokens_output=response.tokens_output, # From response length
|
||||
task_type=TaskType.STORY_PROGRESSION,
|
||||
session_id=session_id,
|
||||
request_duration_ms=duration_ms,
|
||||
success=True
|
||||
)
|
||||
|
||||
# 4. Increment rate limit counter
|
||||
limiter.increment_usage(user_id)
|
||||
|
||||
return {"narrative": response.narrative, ...}
|
||||
```
|
||||
|
||||
### API Endpoint Pattern
|
||||
|
||||
```python
|
||||
@bp.route('/sessions/<session_id>/action', methods=['POST'])
|
||||
@require_auth
|
||||
def take_action(session_id):
|
||||
user = get_current_user()
|
||||
limiter = RateLimiterService()
|
||||
|
||||
# Check limit and return remaining info
|
||||
try:
|
||||
limiter.check_rate_limit(user.id, user.tier)
|
||||
except RateLimitExceeded as e:
|
||||
return api_response(
|
||||
status=429,
|
||||
error={
|
||||
"code": "RATE_LIMIT_EXCEEDED",
|
||||
"message": "Daily turn limit reached",
|
||||
"details": {
|
||||
"limit": e.limit,
|
||||
"current": e.current_usage,
|
||||
"reset_time": e.reset_time.isoformat()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Queue AI job...
|
||||
remaining = limiter.get_remaining_turns(user.id, user.tier)
|
||||
|
||||
return api_response(
|
||||
status=202,
|
||||
result={
|
||||
"job_id": job.id,
|
||||
"remaining_turns": remaining
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appwrite Collection Schema
|
||||
|
||||
**Collection:** `ai_usage_logs`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `log_id` | string | Primary key |
|
||||
| `user_id` | string | User identifier |
|
||||
| `timestamp` | datetime | Request time (UTC) |
|
||||
| `model` | string | Model identifier |
|
||||
| `tokens_input` | integer | Input tokens |
|
||||
| `tokens_output` | integer | Output tokens |
|
||||
| `tokens_total` | integer | Total tokens |
|
||||
| `estimated_cost` | double | Cost in USD |
|
||||
| `task_type` | string | Task type enum |
|
||||
| `session_id` | string | Optional session |
|
||||
| `character_id` | string | Optional character |
|
||||
| `request_duration_ms` | integer | Duration |
|
||||
| `success` | boolean | Success status |
|
||||
| `error_message` | string | Error if failed |
|
||||
|
||||
**Indexes:**
|
||||
- `user_id` + `timestamp` (for daily queries)
|
||||
- `timestamp` (for admin monitoring)
|
||||
|
||||
---
|
||||
|
||||
## Cost Management Best Practices
|
||||
|
||||
### 1. Pre-request Validation
|
||||
|
||||
Always check rate limits before processing:
|
||||
|
||||
```python
|
||||
limiter.check_rate_limit(user_id, user_tier)
|
||||
```
|
||||
|
||||
### 2. Log All Requests
|
||||
|
||||
Log both successful and failed requests:
|
||||
|
||||
```python
|
||||
tracker.log_usage(
|
||||
...,
|
||||
success=False,
|
||||
error_message="Model timeout"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Monitor Platform Costs
|
||||
|
||||
```python
|
||||
# Daily monitoring
|
||||
daily_cost = tracker.get_total_daily_cost(date.today())
|
||||
|
||||
if daily_cost > 50:
|
||||
send_alert("WARNING: Daily AI cost exceeded $50")
|
||||
if daily_cost > 100:
|
||||
send_alert("CRITICAL: Daily AI cost exceeded $100")
|
||||
```
|
||||
|
||||
### 4. Cost Estimation for UI
|
||||
|
||||
Show users estimated costs before actions:
|
||||
|
||||
```python
|
||||
cost_info = UsageTrackingService.get_model_cost_info(model)
|
||||
estimated = (base_tokens * 1.5 / 1000) * (cost_info['input'] + cost_info['output'])
|
||||
```
|
||||
|
||||
### 5. Tier Upgrade Prompts
|
||||
|
||||
When rate limited, prompt upgrades:
|
||||
|
||||
```python
|
||||
if e.user_tier == UserTier.FREE:
|
||||
message = "Upgrade to Basic for 50 turns/day!"
|
||||
elif e.user_tier == UserTier.BASIC:
|
||||
message = "Upgrade to Premium for 100 turns/day!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Target Cost Goals
|
||||
|
||||
- **Development:** < $50/day
|
||||
- **Production target:** < $500/month total
|
||||
- **Cost per user:** ~$0.10/day (premium tier average)
|
||||
|
||||
### Cost Breakdown by Tier (estimated daily)
|
||||
|
||||
| Tier | Avg Requests | Avg Cost/Request | Daily Cost |
|
||||
|------|-------------|-----------------|------------|
|
||||
| FREE | 10 | $0.00 | $0.00 |
|
||||
| BASIC | 30 | $0.003 | $0.09 |
|
||||
| PREMIUM | 60 | $0.01 | $0.60 |
|
||||
| ELITE | 100 | $0.02 | $2.00 |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```python
|
||||
# test_usage_tracking_service.py
|
||||
def test_log_usage():
|
||||
tracker = UsageTrackingService()
|
||||
log = tracker.log_usage(
|
||||
user_id="test_user",
|
||||
model="meta/meta-llama-3-8b-instruct",
|
||||
tokens_input=100,
|
||||
tokens_output=200,
|
||||
task_type=TaskType.STORY_PROGRESSION
|
||||
)
|
||||
assert log.tokens_total == 300
|
||||
assert log.estimated_cost > 0
|
||||
|
||||
# test_rate_limiter_service.py
|
||||
def test_rate_limit_exceeded():
|
||||
limiter = RateLimiterService()
|
||||
|
||||
# Exceed free tier limit
|
||||
for _ in range(20):
|
||||
limiter.increment_usage("test_user")
|
||||
|
||||
with pytest.raises(RateLimitExceeded):
|
||||
limiter.check_rate_limit("test_user", UserTier.FREE)
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
```bash
|
||||
# Check Redis connection
|
||||
redis-cli ping
|
||||
|
||||
# Check Appwrite connection
|
||||
python -c "from app.services.usage_tracking_service import UsageTrackingService; UsageTrackingService()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Deferred)
|
||||
|
||||
- **Task 7.15:** Cost monitoring and alerts (daily job, email alerts)
|
||||
- Billing integration
|
||||
- Usage quotas per session
|
||||
- Real-time cost dashboard
|
||||
- Cost projections
|
||||
Reference in New Issue
Block a user