""" Action Prompt Model This module defines the ActionPrompt dataclass for button-based story actions. Each action prompt represents a predefined action that players can take during story progression, with tier-based availability and context filtering. Usage: from app.models.action_prompt import ActionPrompt, ActionCategory, LocationType action = ActionPrompt( prompt_id="ask_locals", category=ActionCategory.ASK_QUESTION, display_text="Ask locals for information", description="Talk to NPCs to learn about quests and rumors", tier_required=UserTier.FREE, context_filter=[LocationType.TOWN, LocationType.TAVERN], dm_prompt_template="The player asks locals about {{ topic }}..." ) if action.is_available(UserTier.FREE, LocationType.TOWN): # Show action button to player pass """ from dataclasses import dataclass, field from enum import Enum from typing import List, Optional, Any, Dict from app.ai.model_selector import UserTier @dataclass class CheckRequirement: """ Defines the dice check required for an action. Used to determine outcomes before AI narration. """ check_type: str # "search" or "skill" skill: Optional[str] = None # For skill checks: perception, persuasion, etc. difficulty: str = "medium" # trivial, easy, medium, hard, very_hard def to_dict(self) -> dict: """Serialize for API response.""" return { "check_type": self.check_type, "skill": self.skill, "difficulty": self.difficulty, } @classmethod def from_dict(cls, data: dict) -> "CheckRequirement": """Create from dictionary.""" return cls( check_type=data.get("check_type", "skill"), skill=data.get("skill"), difficulty=data.get("difficulty", "medium"), ) class ActionCategory(str, Enum): """Categories of story actions.""" ASK_QUESTION = "ask_question" # Gather information from NPCs TRAVEL = "travel" # Move to a new location GATHER_INFO = "gather_info" # Search or investigate REST = "rest" # Rest and recover INTERACT = "interact" # Interact with objects/environment EXPLORE = "explore" # Explore the area SPECIAL = "special" # Special tier-specific actions class LocationType(str, Enum): """Types of locations in the game world.""" TOWN = "town" # Populated settlements TAVERN = "tavern" # Taverns and inns WILDERNESS = "wilderness" # Outdoor areas, forests, fields DUNGEON = "dungeon" # Dungeons and caves SAFE_AREA = "safe_area" # Protected zones, temples LIBRARY = "library" # Libraries and archives ANY = "any" # Available in all locations @dataclass class ActionPrompt: """ Represents a predefined story action that players can select. Action prompts are displayed as buttons in the story UI. Each action has tier requirements and context filters to determine availability. Attributes: prompt_id: Unique identifier for the action category: Category of action (ASK_QUESTION, TRAVEL, etc.) display_text: Text shown on the action button description: Tooltip/help text explaining the action tier_required: Minimum subscription tier required context_filter: List of location types where action is available dm_prompt_template: Jinja2 template for generating AI prompt icon: Optional icon name for the button cooldown_turns: Optional cooldown in turns before action can be used again """ prompt_id: str category: ActionCategory display_text: str description: str tier_required: UserTier context_filter: List[LocationType] dm_prompt_template: str icon: Optional[str] = None cooldown_turns: int = 0 requires_check: Optional[CheckRequirement] = None def is_available(self, user_tier: UserTier, location_type: LocationType) -> bool: """ Check if this action is available for a user at a location. Args: user_tier: The user's subscription tier location_type: The current location type Returns: True if the action is available, False otherwise """ # Check tier requirement if not self._tier_meets_requirement(user_tier): return False # Check location filter if not self._location_matches_filter(location_type): return False return True def _tier_meets_requirement(self, user_tier: UserTier) -> bool: """ Check if user tier meets the minimum requirement. Tier hierarchy: FREE < BASIC < PREMIUM < ELITE Args: user_tier: The user's subscription tier Returns: True if tier requirement is met """ tier_order = { UserTier.FREE: 0, UserTier.BASIC: 1, UserTier.PREMIUM: 2, UserTier.ELITE: 3, } user_level = tier_order.get(user_tier, 0) required_level = tier_order.get(self.tier_required, 0) return user_level >= required_level def _location_matches_filter(self, location_type: LocationType) -> bool: """ Check if location matches the context filter. Args: location_type: The current location type Returns: True if location matches filter """ # ANY location type matches everything if LocationType.ANY in self.context_filter: return True # Check if location is in the filter list return location_type in self.context_filter def is_locked(self, user_tier: UserTier) -> bool: """ Check if this action is locked due to tier restriction. Used to show locked actions with upgrade prompts. Args: user_tier: The user's subscription tier Returns: True if the action is locked (tier too low) """ return not self._tier_meets_requirement(user_tier) def get_lock_reason(self, user_tier: UserTier) -> Optional[str]: """ Get the reason why an action is locked. Args: user_tier: The user's subscription tier Returns: Lock reason message, or None if not locked """ if not self._tier_meets_requirement(user_tier): tier_names = { UserTier.FREE: "Free", UserTier.BASIC: "Basic", UserTier.PREMIUM: "Premium", UserTier.ELITE: "Elite", } required_name = tier_names.get(self.tier_required, "Unknown") return f"Requires {required_name} tier or higher" return None def to_dict(self) -> dict: """ Convert to dictionary for JSON serialization. Returns: Dictionary representation of the action prompt """ result = { "prompt_id": self.prompt_id, "category": self.category.value, "display_text": self.display_text, "description": self.description, "tier_required": self.tier_required.value, "context_filter": [loc.value for loc in self.context_filter], "dm_prompt_template": self.dm_prompt_template, "icon": self.icon, "cooldown_turns": self.cooldown_turns, } if self.requires_check: result["requires_check"] = self.requires_check.to_dict() return result @classmethod def from_dict(cls, data: dict) -> "ActionPrompt": """ Create an ActionPrompt from a dictionary. Args: data: Dictionary containing action prompt data Returns: ActionPrompt instance Raises: ValueError: If required fields are missing or invalid """ # Parse category enum category_str = data.get("category", "") try: category = ActionCategory(category_str) except ValueError: raise ValueError(f"Invalid action category: {category_str}") # Parse tier enum tier_str = data.get("tier_required", "free") try: tier_required = UserTier(tier_str) except ValueError: raise ValueError(f"Invalid user tier: {tier_str}") # Parse location types context_filter_raw = data.get("context_filter", ["any"]) context_filter = [] for loc_str in context_filter_raw: try: context_filter.append(LocationType(loc_str.lower())) except ValueError: raise ValueError(f"Invalid location type: {loc_str}") # Parse requires_check if present requires_check = None if "requires_check" in data and data["requires_check"]: requires_check = CheckRequirement.from_dict(data["requires_check"]) return cls( prompt_id=data.get("prompt_id", ""), category=category, display_text=data.get("display_text", ""), description=data.get("description", ""), tier_required=tier_required, context_filter=context_filter, dm_prompt_template=data.get("dm_prompt_template", ""), icon=data.get("icon"), cooldown_turns=data.get("cooldown_turns", 0), requires_check=requires_check, ) def __repr__(self) -> str: """String representation for debugging.""" return ( f"ActionPrompt(prompt_id='{self.prompt_id}', " f"category={self.category.value}, " f"tier={self.tier_required.value})" )