first commit

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

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

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

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

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

2309
api/docs/API_REFERENCE.md Normal file

File diff suppressed because it is too large Load Diff

1864
api/docs/API_TESTING.md Normal file

File diff suppressed because it is too large Load Diff

351
api/docs/APPWRITE_SETUP.md Normal file
View 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

File diff suppressed because it is too large Load Diff

587
api/docs/GAME_SYSTEMS.md Normal file
View 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
View 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

View 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

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

View 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

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