15 KiB
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
@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
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
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
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
# 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
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
# 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
# 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
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
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
{{ 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
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:
- Sessions API loads the action prompt and extracts
dm_prompt_template - AI task receives it in the context as
dm_prompt_template - NarrativeGenerator receives it as
action_instructionsparameter - story_action.j2 injects it under "Action-Specific Instructions"
# 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:
# 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
@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
{% 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
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
- 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
loader = ActionPromptLoader()
loader.reload("app/data/action_prompts.yaml")
3. Test
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.
cooldown_turns: 3 # Cannot use for 3 turns after use
Cooldown tracking should be implemented in the session/game state.
Testing
Unit Tests
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
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")