348 lines
14 KiB
Python
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()
|
|
},
|
|
}
|