# 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//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 %} {% endfor %} {% for action in locked_actions %} {% 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") ```