348 lines
14 KiB
Python

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