Source code for barebones_rpg.dialog.dialog

"""Dialog system for conversations and choice trees.

This module provides a flexible dialog system for NPC conversations,
branching narratives, and interactive storytelling.
"""

from typing import Optional, List, Dict, Any, Callable, Tuple
from uuid import uuid4
from pydantic import BaseModel, Field


[docs] class DialogConditions: """Helper class with common dialog condition factories. This provides reusable condition functions for common dialog scenarios, reducing boilerplate in dialog tree creation. Example: >>> from barebones_rpg.quests.quest import QuestStatus >>> quest_started = DialogConditions.quest_status(quest, QuestStatus.ACTIVE) >>> enemy_dead = DialogConditions.entity_not_in_location(location, "Goblin") """
[docs] @staticmethod def quest_status(quest, status) -> Callable: """Create a condition that checks if a quest has a specific status. Args: quest: Quest object to check status: QuestStatus to check for Returns: Condition function """ def condition(context: Dict[str, Any]) -> bool: return quest.status == status return condition
[docs] @staticmethod def quest_not_started(quest) -> Callable: """Create a condition that checks if a quest hasn't been started. Args: quest: Quest object to check Returns: Condition function """ from ..quests.quest import QuestStatus return DialogConditions.quest_status(quest, QuestStatus.NOT_STARTED)
[docs] @staticmethod def quest_active(quest) -> Callable: """Create a condition that checks if a quest is active. Args: quest: Quest object to check Returns: Condition function """ from ..quests.quest import QuestStatus return DialogConditions.quest_status(quest, QuestStatus.ACTIVE)
[docs] @staticmethod def quest_completed(quest) -> Callable: """Create a condition that checks if a quest is completed. Args: quest: Quest object to check Returns: Condition function """ from ..quests.quest import QuestStatus return DialogConditions.quest_status(quest, QuestStatus.COMPLETED)
[docs] @staticmethod def entity_in_location(location, entity_name: str) -> Callable: """Create a condition that checks if an entity exists in a location. Args: location: Location object to check entity_name: Name of entity to look for Returns: Condition function """ def condition(context: Dict[str, Any]) -> bool: return location.has_entity_named(entity_name) return condition
[docs] @staticmethod def entity_not_in_location(location, entity_name: str) -> Callable: """Create a condition that checks if an entity does NOT exist in a location. Args: location: Location object to check entity_name: Name of entity to look for Returns: Condition function """ def condition(context: Dict[str, Any]) -> bool: return not location.has_entity_named(entity_name) return condition
[docs] @staticmethod def all_conditions(*conditions: Callable) -> Callable: """Create a condition that requires all sub-conditions to be true. Args: *conditions: Variable number of condition functions Returns: Combined condition function """ def condition(context: Dict[str, Any]) -> bool: return all(cond(context) for cond in conditions) return condition
[docs] @staticmethod def any_condition(*conditions: Callable) -> Callable: """Create a condition that requires any sub-condition to be true. Args: *conditions: Variable number of condition functions Returns: Combined condition function """ def condition(context: Dict[str, Any]) -> bool: return any(cond(context) for cond in conditions) return condition
[docs] @staticmethod def not_condition(condition_func: Callable) -> Callable: """Create a condition that inverts another condition. Args: condition_func: Condition to invert Returns: Inverted condition function """ def condition(context: Dict[str, Any]) -> bool: return not condition_func(context) return condition
[docs] @staticmethod def always() -> Callable: """Create a condition that is always true. Useful as a fallback or when you want an option always available. Returns: Always-true condition function """ def condition(context: Dict[str, Any]) -> bool: return True return condition
[docs] class DialogChoice(BaseModel): """A choice in a dialog tree. Example: >>> choice = DialogChoice( ... text="Tell me about the quest", ... next_node_id="quest_info" ... ) >>> # With quest integration >>> choice = DialogChoice( ... text="I'll help you!", ... next_node_id="accepted", ... quest_to_start=my_quest ... ) """ text: str = Field(description="Choice text shown to player") next_node_id: Optional[str] = Field( default=None, description="ID of next dialog node (None = end dialog)" ) condition: Optional[Callable] = Field( default=None, description="Function that returns True if choice is available" ) on_select: Optional[Callable] = Field( default=None, description="Function called when choice is selected" ) # Quest integration quest_to_start: Optional[Any] = Field( default=None, description="Quest object to start when this choice is selected" ) quest_to_update: Optional[tuple] = Field( default=None, description="Tuple of (quest, objective_type, target, amount) to update when selected", ) metadata: Dict[str, Any] = Field(default_factory=dict, description="Custom data") model_config = {"arbitrary_types_allowed": True}
[docs] def is_available(self, context: Dict[str, Any]) -> bool: """Check if this choice is available. Args: context: Game context for evaluating conditions Returns: True if choice can be selected """ if self.condition is None: return True return self.condition(context)
[docs] def select(self, context: Dict[str, Any]) -> Any: """Execute the choice's on_select callback and handle quest actions. Args: context: Game context Returns: Result of callback (if any) """ # Handle quest starting if self.quest_to_start: events = context.get("events") quest_manager = context.get("quest_manager") location = context.get("location") world = context.get("world") if quest_manager and events: quest_manager.start_quest(self.quest_to_start.id, events) elif events: # Direct start if no manager in context # Pass location/world for retroactive progress checking self.quest_to_start.start(events, location=location, world=world) # Handle quest updating if self.quest_to_update: from ..quests.quest import ObjectiveType quest, objective_type, target, amount = self.quest_to_update events = context.get("events") quest_manager = context.get("quest_manager") if quest_manager and events: quest_manager.update_objective( quest.id, objective_type, target, amount, events ) # Execute custom callback if self.on_select: return self.on_select(context) return None
[docs] class DialogNode(BaseModel): """A node in a dialog tree. Each node contains text, optional speaker, and choices for the player. Example: >>> node = DialogNode( ... id="greeting", ... speaker="Village Elder", ... text="Welcome, traveler. How can I help you?", ... choices=[ ... DialogChoice(text="Tell me about the village", next_node_id="village_info"), ... DialogChoice(text="Goodbye", next_node_id=None) ... ] ... ) """ id: str = Field(default_factory=lambda: str(uuid4()), description="Unique node ID") speaker: Optional[str] = Field(default=None, description="Speaker name") text: str = Field(description="Dialog text") choices: List[DialogChoice] = Field( default_factory=list, description="Available choices" ) on_enter: Optional[Callable] = Field( default=None, description="Function called when node is entered" ) on_exit: Optional[Callable] = Field( default=None, description="Function called when node is exited" ) metadata: Dict[str, Any] = Field(default_factory=dict, description="Custom data") model_config = {"arbitrary_types_allowed": True}
[docs] def get_available_choices(self, context: Dict[str, Any]) -> List[DialogChoice]: """Get all available choices based on current context. Args: context: Game context for evaluating conditions Returns: List of available choices """ return [choice for choice in self.choices if choice.is_available(context)]
[docs] def enter(self, context: Dict[str, Any]) -> Any: """Call the on_enter callback. Args: context: Game context Returns: Result of callback (if any) """ if self.on_enter: return self.on_enter(context) return None
[docs] def exit(self, context: Dict[str, Any]) -> Any: """Call the on_exit callback. Args: context: Game context Returns: Result of callback (if any) """ if self.on_exit: return self.on_exit(context) return None
[docs] class DialogTree(BaseModel): """A complete dialog tree. Contains all nodes and manages navigation through the dialog. Example: >>> tree = DialogTree(name="Village Elder Dialog") >>> tree.add_node(DialogNode( ... id="start", ... speaker="Elder", ... text="Hello!", ... choices=[DialogChoice(text="Hi", next_node_id="greeting")] ... )) >>> tree.set_start_node("start") """ id: str = Field(default_factory=lambda: str(uuid4()), description="Unique tree ID") name: str = Field(description="Dialog tree name") nodes: Dict[str, DialogNode] = Field( default_factory=dict, description="All nodes in the tree" ) start_node_id: Optional[str] = Field( default=None, description="ID of starting node" ) metadata: Dict[str, Any] = Field(default_factory=dict, description="Custom data") model_config = {"arbitrary_types_allowed": True}
[docs] def add_node(self, node: DialogNode) -> bool: """Add a node to the tree. Args: node: Node to add Returns: True if node was added successfully """ self.nodes[node.id] = node # Auto-set start node if this is the first node if self.start_node_id is None: self.start_node_id = node.id return True
[docs] def get_node(self, node_id: str) -> Optional[DialogNode]: """Get a node by ID. Args: node_id: ID of node to retrieve Returns: Dialog node or None if not found """ return self.nodes.get(node_id)
[docs] def get_start_node(self) -> Optional[DialogNode]: """Get the starting node. Returns: Start node or None if not set """ if self.start_node_id: return self.nodes.get(self.start_node_id) return None
[docs] def set_start_node(self, node_id: str) -> bool: """Set the starting node. Args: node_id: ID of the start node Returns: True if node exists and was set as start node """ if node_id in self.nodes: self.start_node_id = node_id return True return False
[docs] def validate_tree(self) -> List[str]: """Validate the dialog tree. Returns: List of validation errors (empty if valid) """ errors = [] if not self.start_node_id: errors.append("No start node set") elif self.start_node_id not in self.nodes: errors.append(f"Start node '{self.start_node_id}' not found") # Check that all referenced nodes exist for node in self.nodes.values(): for choice in node.choices: if choice.next_node_id and choice.next_node_id not in self.nodes: errors.append( f"Node '{node.id}' references non-existent node '{choice.next_node_id}'" ) return errors
[docs] class DialogSession: """Active dialog session. Manages the state of an ongoing conversation. The framework automatically populates the context with game systems when a game instance is provided. Example: >>> tree = DialogTree(name="Test") >>> # ... add nodes ... >>> # Simple - just pass game, framework handles context >>> session = DialogSession(tree, game=game) >>> # Or add custom context >>> session = DialogSession(tree, game=game, context={"npc_mood": "happy"}) """
[docs] def __init__( self, dialog_tree: DialogTree, game: Optional[Any] = None, context: Optional[Dict[str, Any]] = None, ): """Initialize a dialog session. Args: dialog_tree: The dialog tree to run game: Game instance (framework auto-populates context from it) context: Additional custom context data """ self.tree = dialog_tree self.context = context or {} # Auto-populate context from game if provided if game: self.context["game"] = game self.context["quest_manager"] = game.quests self.context["events"] = game.events # Note: world property not added yet, can be added when needed self.current_node_id: Optional[str] = None self.history: List[str] = [] # Node IDs visited self.is_active = False
[docs] def start(self) -> Optional[DialogNode]: """Start the dialog session. Returns: The starting dialog node """ start_node = self.tree.get_start_node() if start_node: self.current_node_id = start_node.id self.is_active = True self.history.append(start_node.id) start_node.enter(self.context) return start_node return None
[docs] def get_current_node(self) -> Optional[DialogNode]: """Get the current dialog node. Returns: Current node or None """ if self.current_node_id: return self.tree.get_node(self.current_node_id) return None
[docs] def get_available_choices(self) -> List[DialogChoice]: """Get available choices at current node. Returns: List of available choices """ node = self.get_current_node() if node: return node.get_available_choices(self.context) return []
[docs] def make_choice(self, choice_index: int) -> Optional[DialogNode]: """Make a choice and navigate to next node. Args: choice_index: Index of the choice to make Returns: Next dialog node or None if dialog ended """ if not self.is_active: return None current = self.get_current_node() if not current: return None choices = self.get_available_choices() if choice_index < 0 or choice_index >= len(choices): return None choice = choices[choice_index] # Exit current node current.exit(self.context) # Execute choice callback choice.select(self.context) # Navigate to next node if choice.next_node_id: next_node = self.tree.get_node(choice.next_node_id) if next_node: self.current_node_id = next_node.id self.history.append(next_node.id) next_node.enter(self.context) return next_node else: # Dialog ended self.end() return None
[docs] def end(self) -> None: """End the dialog session.""" if self.current_node_id: current = self.get_current_node() if current: current.exit(self.context) self.is_active = False self.current_node_id = None
# Helper function for creating simple linear dialogs
[docs] def create_linear_dialog( name: str, conversations: List[Tuple[str, str]], speaker: Optional[str] = None ) -> DialogTree: """Create a simple linear dialog (no branching). Args: name: Dialog name conversations: List of (text, speaker) tuples speaker: Default speaker name Returns: Linear dialog tree """ tree = DialogTree(name=name) for i, (text, node_speaker) in enumerate(conversations): node_id = f"node_{i}" next_node_id = f"node_{i+1}" if i < len(conversations) - 1 else None node = DialogNode( id=node_id, speaker=node_speaker or speaker, text=text, choices=( [DialogChoice(text="Continue", next_node_id=next_node_id)] if next_node_id else [] ), ) tree.add_node(node) return tree