"""Desire System for BDI agents. Desires represent high-level, long-term motivations that persist across turns. Unlike GOAP goals (which are immediate targets), desires are personality-driven and influence which goals get activated. Key concepts: - Desires are weighted by personality traits - Desires can be satisfied temporarily but return - Desires influence goal selection, not direct actions """ from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from backend.core.bdi.belief import BeliefBase from backend.domain.personality import PersonalityTraits from backend.core.goap.goal import Goal class DesireType(Enum): """Types of high-level desires.""" SURVIVAL = "survival" # Stay alive (thirst, hunger, heat, energy) ACCUMULATE_WEALTH = "wealth" # Build up money reserves STOCK_RESOURCES = "stock" # Hoard resources for security MASTER_PROFESSION = "mastery" # Improve skills in preferred activity SOCIAL_STANDING = "social" # Gain reputation through trade COMFORT = "comfort" # Maintain clothes, warmth, rest @dataclass class Desire: """A high-level motivation that influences goal selection. Desires have: - A base intensity (how strongly the agent wants this) - A satisfaction level (0-1, how fulfilled is this desire currently) - A personality weight (how much this agent's personality cares) """ desire_type: DesireType base_intensity: float = 1.0 # Base importance (0-2) satisfaction: float = 0.5 # Current fulfillment (0-1) personality_weight: float = 1.0 # Multiplier from personality # Persistence tracking turns_pursued: int = 0 # How long we've been chasing this turns_since_progress: int = 0 # Turns since we made progress @property def effective_intensity(self) -> float: """Calculate current desire intensity considering satisfaction.""" # Desire is stronger when unsatisfied unsatisfied = 1.0 - self.satisfaction # Apply personality weight intensity = self.base_intensity * self.personality_weight * (0.5 + unsatisfied) # Reduce intensity if pursued too long without progress (boredom) if self.turns_since_progress > 10: boredom_factor = max(0.3, 1.0 - (self.turns_since_progress - 10) * 0.05) intensity *= boredom_factor return intensity def update_satisfaction(self, new_satisfaction: float) -> None: """Update satisfaction level and track progress.""" if new_satisfaction > self.satisfaction: self.turns_since_progress = 0 # Made progress! else: self.turns_since_progress += 1 self.satisfaction = max(0.0, min(1.0, new_satisfaction)) self.turns_pursued += 1 def reset_pursuit(self) -> None: """Reset pursuit tracking (when switching to different desire).""" self.turns_pursued = 0 self.turns_since_progress = 0 class DesireManager: """Manages an agent's desires and converts them to active goals. The DesireManager: 1. Maintains a set of desires weighted by personality 2. Updates desire satisfaction based on beliefs 3. Selects which desires should drive current goals 4. Filters/prioritizes GOAP goals based on active desires """ def __init__(self, personality: "PersonalityTraits"): """Initialize desires based on personality.""" self.desires: dict[DesireType, Desire] = {} self._init_desires(personality) # Track which desire is currently dominant self.dominant_desire: Optional[DesireType] = None self.stubbornness: float = 0.5 # How reluctant to switch desires def _init_desires(self, personality: "PersonalityTraits") -> None: """Initialize desires with personality-based weights.""" # Survival - everyone has this, but risk-averse agents weight it higher self.desires[DesireType.SURVIVAL] = Desire( desire_type=DesireType.SURVIVAL, base_intensity=2.0, # Always high base personality_weight=1.5 - personality.risk_tolerance * 0.5, ) # Accumulate Wealth - driven by wealth_desire self.desires[DesireType.ACCUMULATE_WEALTH] = Desire( desire_type=DesireType.ACCUMULATE_WEALTH, base_intensity=1.0, personality_weight=0.5 + personality.wealth_desire, ) # Stock Resources - driven by hoarding_rate self.desires[DesireType.STOCK_RESOURCES] = Desire( desire_type=DesireType.STOCK_RESOURCES, base_intensity=0.8, personality_weight=0.5 + personality.hoarding_rate, ) # Master Profession - driven by strongest preference max_pref = max( personality.hunt_preference, personality.gather_preference, personality.trade_preference, ) self.desires[DesireType.MASTER_PROFESSION] = Desire( desire_type=DesireType.MASTER_PROFESSION, base_intensity=0.6, personality_weight=max_pref * 0.5, ) # Social Standing - driven by market_affinity and trade_preference self.desires[DesireType.SOCIAL_STANDING] = Desire( desire_type=DesireType.SOCIAL_STANDING, base_intensity=0.5, personality_weight=personality.market_affinity * personality.trade_preference * 0.5, ) # Comfort - moderate for everyone self.desires[DesireType.COMFORT] = Desire( desire_type=DesireType.COMFORT, base_intensity=0.7, personality_weight=1.0 - personality.risk_tolerance * 0.3, ) # Calculate stubbornness from personality # High hoarding + low risk tolerance = stubborn self.stubbornness = (personality.hoarding_rate + (1.0 - personality.risk_tolerance)) / 2 def update_from_beliefs(self, beliefs: "BeliefBase") -> None: """Update desire satisfaction based on current beliefs.""" # Survival satisfaction based on vitals min_vital = min( beliefs.thirst_pct, beliefs.hunger_pct, beliefs.heat_pct, beliefs.energy_pct, ) self.desires[DesireType.SURVIVAL].update_satisfaction(min_vital) # Wealth satisfaction based on money relative to wealthy threshold if beliefs.is_wealthy: self.desires[DesireType.ACCUMULATE_WEALTH].update_satisfaction(0.8) else: # Scale satisfaction by money (rough approximation) wealth_sat = min(1.0, beliefs.money / 10000) self.desires[DesireType.ACCUMULATE_WEALTH].update_satisfaction(wealth_sat) # Stock satisfaction based on inventory total_resources = ( beliefs.water_count + beliefs.food_count + beliefs.wood_count ) stock_sat = min(1.0, total_resources / 15) # 15 resources = satisfied self.desires[DesireType.STOCK_RESOURCES].update_satisfaction(stock_sat) # Mastery satisfaction based on skill levels max_skill = max( beliefs.hunting_skill, beliefs.gathering_skill, beliefs.trading_skill, ) mastery_sat = (max_skill - 1.0) / 1.0 # 0 at skill 1.0, 1 at skill 2.0 self.desires[DesireType.MASTER_PROFESSION].update_satisfaction(max(0, mastery_sat)) # Social satisfaction - harder to measure, use trade success trade_success = max(0, 5 - beliefs.failed_trades) / 5 self.desires[DesireType.SOCIAL_STANDING].update_satisfaction(trade_success) # Comfort satisfaction comfort_factors = [ beliefs.energy_pct, beliefs.heat_pct, 1.0 if beliefs.has_clothes else 0.5, ] comfort_sat = sum(comfort_factors) / len(comfort_factors) self.desires[DesireType.COMFORT].update_satisfaction(comfort_sat) def get_active_desires(self) -> list[Desire]: """Get desires sorted by effective intensity (highest first).""" return sorted( self.desires.values(), key=lambda d: d.effective_intensity, reverse=True, ) def should_switch_desire(self, new_dominant: DesireType) -> bool: """Determine if we should switch dominant desire. Stubborn agents require a larger intensity difference to switch. """ if self.dominant_desire is None: return True if new_dominant == self.dominant_desire: return False current = self.desires[self.dominant_desire] new = self.desires[new_dominant] # Calculate required intensity difference based on stubbornness # Stubbornness 0.5 = need 20% more intensity to switch # Stubbornness 1.0 = need 50% more intensity to switch required_difference = 1.0 + self.stubbornness * 0.5 return new.effective_intensity > current.effective_intensity * required_difference def update_dominant_desire(self) -> DesireType: """Update and return the currently dominant desire.""" active = self.get_active_desires() if not active: return DesireType.SURVIVAL top_desire = active[0].desire_type if self.should_switch_desire(top_desire): # Reset pursuit on old desire if switching if self.dominant_desire and self.dominant_desire != top_desire: self.desires[self.dominant_desire].reset_pursuit() self.dominant_desire = top_desire return self.dominant_desire def filter_goals_by_desire( self, goals: list["Goal"], beliefs: "BeliefBase", ) -> list["Goal"]: """Filter and re-prioritize goals based on active desires. This is where desires influence GOAP goal selection: - Goals aligned with dominant desire get priority boost - Goals conflicting with desires get reduced priority """ # Update dominant desire dominant = self.update_dominant_desire() # Get world state for goal priority calculation world_state = beliefs.to_world_state() # Calculate goal priorities with desire modifiers goal_priorities: list[tuple["Goal", float]] = [] for goal in goals: base_priority = goal.get_priority(world_state) # Apply desire-based modifiers modifier = self._get_goal_desire_modifier(goal.name, dominant, beliefs) adjusted_priority = base_priority * modifier goal_priorities.append((goal, adjusted_priority)) # Sort by adjusted priority goal_priorities.sort(key=lambda x: x[1], reverse=True) # Return goals (the priorities will be recalculated by planner, # but we've filtered/ordered them by our preferences) return [g for g, _ in goal_priorities] def _get_goal_desire_modifier( self, goal_name: str, dominant_desire: DesireType, beliefs: "BeliefBase", ) -> float: """Get priority modifier for a goal based on dominant desire.""" goal_lower = goal_name.lower() # Map goals to desires they serve goal_desire_map = { # Survival goals "satisfy thirst": DesireType.SURVIVAL, "satisfy hunger": DesireType.SURVIVAL, "maintain heat": DesireType.SURVIVAL, "restore energy": DesireType.SURVIVAL, # Wealth goals "build wealth": DesireType.ACCUMULATE_WEALTH, "sell excess": DesireType.ACCUMULATE_WEALTH, "find deals": DesireType.ACCUMULATE_WEALTH, "trader arbitrage": DesireType.ACCUMULATE_WEALTH, # Stock goals "stock water": DesireType.STOCK_RESOURCES, "stock food": DesireType.STOCK_RESOURCES, "stock wood": DesireType.STOCK_RESOURCES, # Comfort goals "get clothes": DesireType.COMFORT, "sleep": DesireType.COMFORT, } # Find which desire this goal serves goal_desire = None for gn, desire in goal_desire_map.items(): if gn in goal_lower: goal_desire = desire break if goal_desire is None: return 1.0 # No modifier for unknown goals # Boost if aligned with dominant desire if goal_desire == dominant_desire: return 1.3 + self.desires[dominant_desire].effective_intensity * 0.2 # Survival always gets a baseline boost if critical if goal_desire == DesireType.SURVIVAL and beliefs.has_critical_need(): return 2.0 # Critical needs override other desires # Slight reduction for non-aligned goals return 0.9 def to_dict(self) -> dict: """Convert to dictionary for debugging/logging.""" return { "dominant_desire": self.dominant_desire.value if self.dominant_desire else None, "stubbornness": round(self.stubbornness, 2), "desires": { d.desire_type.value: { "intensity": round(d.effective_intensity, 2), "satisfaction": round(d.satisfaction, 2), "personality_weight": round(d.personality_weight, 2), "turns_pursued": d.turns_pursued, } for d in self.desires.values() }, }