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