297 lines
9.7 KiB
Python
297 lines
9.7 KiB
Python
"""
|
|
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})"
|
|
)
|