Files
Code_of_Conquest/api/docs/ACTION_PROMPTS.md
2025-11-24 23:10:55 -06:00

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:

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