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")
```