Files
Code_of_Conquest/api/app/models/action_prompt.py
2025-11-24 23:10:55 -06:00

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