first commit
This commit is contained in:
296
api/app/models/action_prompt.py
Normal file
296
api/app/models/action_prompt.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
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})"
|
||||
)
|
||||
Reference in New Issue
Block a user