villsim/backend/core/bdi/belief.py

402 lines
15 KiB
Python

"""Belief System for BDI agents.
The BeliefBase maintains persistent state about the world from the agent's
perspective, including:
- Current sensory data (vitals, inventory, market)
- Memory of past events (failed trades, good hunting spots)
- Dirty flags for efficient updates
This replaces the transient WorldState creation with a persistent belief system.
"""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
from collections import deque
if TYPE_CHECKING:
from backend.domain.agent import Agent
from backend.core.market import OrderBook
@dataclass
class MemoryEvent:
"""A remembered event that may influence future decisions."""
event_type: str # "trade_failed", "hunt_failed", "good_deal", etc.
turn: int
data: dict = field(default_factory=dict)
relevance: float = 1.0 # Decays over time
@dataclass
class BeliefBase:
"""Persistent belief system for a BDI agent.
Maintains both current perceptions and memories of past events.
Uses dirty flags to avoid unnecessary recomputation.
"""
# Current perception state (cached WorldState fields)
thirst_pct: float = 1.0
hunger_pct: float = 1.0
heat_pct: float = 1.0
energy_pct: float = 1.0
# Resource counts
water_count: int = 0
food_count: int = 0
meat_count: int = 0
berries_count: int = 0
wood_count: int = 0
hide_count: int = 0
# Inventory state
has_clothes: bool = False
inventory_space: int = 0
inventory_full: bool = False
# Economic state
money: int = 0
is_wealthy: bool = False
# Market beliefs (what we believe about market availability)
can_buy_water: bool = False
can_buy_food: bool = False
can_buy_meat: bool = False
can_buy_berries: bool = False
can_buy_wood: bool = False
water_market_price: int = 0
food_market_price: int = 0
wood_market_price: int = 0
# Time beliefs
is_night: bool = False
is_evening: bool = False
step_in_day: int = 0
day_steps: int = 10
current_turn: int = 0
# Personality (cached from agent, rarely changes)
wealth_desire: float = 0.5
hoarding_rate: float = 0.5
risk_tolerance: float = 0.5
market_affinity: float = 0.5
is_trader: bool = False
gather_preference: float = 1.0
hunt_preference: float = 1.0
trade_preference: float = 1.0
# Skills
hunting_skill: float = 1.0
gathering_skill: float = 1.0
trading_skill: float = 1.0
# Thresholds (from config)
critical_threshold: float = 0.25
low_threshold: float = 0.45
# Calculated urgencies
thirst_urgency: float = 0.0
hunger_urgency: float = 0.0
heat_urgency: float = 0.0
energy_urgency: float = 0.0
# === BDI Extensions ===
# Memory system - stores past events that influence decisions
memories: deque = field(default_factory=lambda: deque(maxlen=50))
# Track failed actions for this resource type (turn -> count)
failed_hunts: int = 0
failed_gathers: int = 0
failed_trades: int = 0
# Track successful trades with agents (agent_id -> positive count)
trusted_traders: dict = field(default_factory=dict)
# Track failed trades with agents (agent_id -> negative count)
distrusted_traders: dict = field(default_factory=dict)
# Dirty flags for optimization
_vitals_dirty: bool = True
_inventory_dirty: bool = True
_market_dirty: bool = True
_last_update_turn: int = -1
def update_from_sensors(
self,
agent: "Agent",
market: "OrderBook",
step_in_day: int = 1,
day_steps: int = 10,
current_turn: int = 0,
is_night: bool = False,
) -> None:
"""Update beliefs from current agent and market state.
Uses dirty flags to skip unchanged data.
"""
from backend.domain.resources import ResourceType
from backend.config import get_config
self.current_turn = current_turn
self.step_in_day = step_in_day
self.day_steps = day_steps
self.is_night = is_night
self.is_evening = step_in_day >= day_steps - 2
# Always update vitals (they change every turn)
stats = agent.stats
self.thirst_pct = stats.thirst / stats.MAX_THIRST
self.hunger_pct = stats.hunger / stats.MAX_HUNGER
self.heat_pct = stats.heat / stats.MAX_HEAT
self.energy_pct = stats.energy / stats.MAX_ENERGY
# Update inventory
self.water_count = agent.get_resource_count(ResourceType.WATER)
self.meat_count = agent.get_resource_count(ResourceType.MEAT)
self.berries_count = agent.get_resource_count(ResourceType.BERRIES)
self.wood_count = agent.get_resource_count(ResourceType.WOOD)
self.hide_count = agent.get_resource_count(ResourceType.HIDE)
self.food_count = self.meat_count + self.berries_count
self.has_clothes = agent.has_clothes()
self.inventory_space = agent.inventory_space()
self.inventory_full = agent.inventory_full()
self.money = agent.money
# Update personality (cached, rarely changes)
self.wealth_desire = agent.personality.wealth_desire
self.hoarding_rate = agent.personality.hoarding_rate
self.risk_tolerance = agent.personality.risk_tolerance
self.market_affinity = agent.personality.market_affinity
self.gather_preference = agent.personality.gather_preference
self.hunt_preference = agent.personality.hunt_preference
self.trade_preference = agent.personality.trade_preference
# Skills
self.hunting_skill = agent.skills.hunting
self.gathering_skill = agent.skills.gathering
self.trading_skill = agent.skills.trading
# Market availability
def get_market_info(resource_type: ResourceType) -> tuple[bool, int]:
order = market.get_cheapest_order(resource_type)
if order and order.seller_id != agent.id and agent.money >= order.price_per_unit:
return True, order.price_per_unit
return False, 0
self.can_buy_water, self.water_market_price = get_market_info(ResourceType.WATER)
self.can_buy_meat, meat_price = get_market_info(ResourceType.MEAT)
self.can_buy_berries, berries_price = get_market_info(ResourceType.BERRIES)
self.can_buy_wood, self.wood_market_price = get_market_info(ResourceType.WOOD)
self.can_buy_food = self.can_buy_meat or self.can_buy_berries
food_price = min(
meat_price if self.can_buy_meat else float('inf'),
berries_price if self.can_buy_berries else float('inf')
)
self.food_market_price = int(food_price) if food_price != float('inf') else 0
# Wealth calculation
config = get_config()
economy_config = getattr(config, 'economy', None)
min_wealth_target = getattr(economy_config, 'min_wealth_target', 5000) if economy_config else 5000
wealth_target = int(min_wealth_target * (0.5 + self.wealth_desire))
self.is_wealthy = self.money >= wealth_target
# Trader check
self.is_trader = self.trade_preference > 1.3 and self.market_affinity > 0.5
# Config thresholds
agent_config = config.agent_stats
self.critical_threshold = agent_config.critical_threshold
self.low_threshold = 0.45
# Calculate urgencies
self._calculate_urgencies()
# Decay old memories
self._decay_memories()
self._last_update_turn = current_turn
def _calculate_urgencies(self) -> None:
"""Calculate urgency values for each vital stat."""
def calc_urgency(pct: float, critical: float, low: float) -> float:
if pct >= low:
return 0.0
elif pct >= critical:
return 1.0 - (pct - critical) / (low - critical)
else:
return 1.0 + (critical - pct) / critical * 2.0
self.thirst_urgency = calc_urgency(self.thirst_pct, self.critical_threshold, self.low_threshold)
self.hunger_urgency = calc_urgency(self.hunger_pct, self.critical_threshold, self.low_threshold)
self.heat_urgency = calc_urgency(self.heat_pct, self.critical_threshold, self.low_threshold)
if self.energy_pct < 0.25:
self.energy_urgency = 2.0
elif self.energy_pct < 0.40:
self.energy_urgency = 1.0
else:
self.energy_urgency = 0.0
def _decay_memories(self) -> None:
"""Decay memory relevance over time."""
for memory in self.memories:
age = self.current_turn - memory.turn
# Memories decay exponentially with age
memory.relevance = max(0.1, 1.0 / (1.0 + age * 0.1))
def add_memory(self, event_type: str, data: dict = None) -> None:
"""Add a new memory event."""
self.memories.append(MemoryEvent(
event_type=event_type,
turn=self.current_turn,
data=data or {},
relevance=1.0,
))
def record_failed_action(self, action_type: str) -> None:
"""Record a failed action for learning."""
if action_type == "hunt":
self.failed_hunts += 1
self.add_memory("hunt_failed")
elif action_type == "gather":
self.failed_gathers += 1
self.add_memory("gather_failed")
elif action_type == "trade":
self.failed_trades += 1
self.add_memory("trade_failed")
def record_successful_action(self, action_type: str) -> None:
"""Record a successful action, reducing failure counts."""
if action_type == "hunt":
self.failed_hunts = max(0, self.failed_hunts - 1)
elif action_type == "gather":
self.failed_gathers = max(0, self.failed_gathers - 1)
elif action_type == "trade":
self.failed_trades = max(0, self.failed_trades - 1)
def record_trade_partner(self, partner_id: str, success: bool) -> None:
"""Track trade relationship with another agent."""
if success:
self.trusted_traders[partner_id] = self.trusted_traders.get(partner_id, 0) + 1
# Reduce distrust if they've been bad before
if partner_id in self.distrusted_traders:
self.distrusted_traders[partner_id] = max(0, self.distrusted_traders[partner_id] - 1)
else:
self.distrusted_traders[partner_id] = self.distrusted_traders.get(partner_id, 0) + 1
def get_trade_trust(self, partner_id: str) -> float:
"""Get trust level for a trade partner (-1 to 1)."""
trust = self.trusted_traders.get(partner_id, 0)
distrust = self.distrusted_traders.get(partner_id, 0)
total = trust + distrust
if total == 0:
return 0.0 # Unknown partner
return (trust - distrust) / total
def has_critical_need(self) -> bool:
"""Check if any vital stat is critical (requires immediate attention)."""
return (
self.thirst_urgency >= 2.0 or
self.hunger_urgency >= 2.0 or
self.heat_urgency >= 2.0 or
self.energy_urgency >= 2.0
)
def get_most_urgent_need(self) -> Optional[str]:
"""Get the most urgent vital need, if any."""
urgencies = {
"thirst": self.thirst_urgency,
"hunger": self.hunger_urgency,
"heat": self.heat_urgency,
"energy": self.energy_urgency,
}
max_urgency = max(urgencies.values())
if max_urgency < 0.5:
return None
return max(urgencies, key=urgencies.get)
def to_world_state(self):
"""Convert beliefs to a WorldState for GOAP planner compatibility."""
from backend.core.goap.world_state import WorldState
return WorldState(
thirst_pct=self.thirst_pct,
hunger_pct=self.hunger_pct,
heat_pct=self.heat_pct,
energy_pct=self.energy_pct,
water_count=self.water_count,
food_count=self.food_count,
meat_count=self.meat_count,
berries_count=self.berries_count,
wood_count=self.wood_count,
hide_count=self.hide_count,
has_clothes=self.has_clothes,
inventory_space=self.inventory_space,
inventory_full=self.inventory_full,
money=self.money,
is_wealthy=self.is_wealthy,
can_buy_water=self.can_buy_water,
can_buy_food=self.can_buy_food,
can_buy_meat=self.can_buy_meat,
can_buy_berries=self.can_buy_berries,
can_buy_wood=self.can_buy_wood,
water_market_price=self.water_market_price,
food_market_price=self.food_market_price,
wood_market_price=self.wood_market_price,
is_night=self.is_night,
is_evening=self.is_evening,
step_in_day=self.step_in_day,
day_steps=self.day_steps,
wealth_desire=self.wealth_desire,
hoarding_rate=self.hoarding_rate,
risk_tolerance=self.risk_tolerance,
market_affinity=self.market_affinity,
is_trader=self.is_trader,
gather_preference=self.gather_preference,
hunt_preference=self.hunt_preference,
trade_preference=self.trade_preference,
hunting_skill=self.hunting_skill,
gathering_skill=self.gathering_skill,
trading_skill=self.trading_skill,
critical_threshold=self.critical_threshold,
low_threshold=self.low_threshold,
)
def to_dict(self) -> dict:
"""Convert to dictionary for debugging/logging."""
return {
"vitals": {
"thirst": round(self.thirst_pct, 2),
"hunger": round(self.hunger_pct, 2),
"heat": round(self.heat_pct, 2),
"energy": round(self.energy_pct, 2),
},
"urgencies": {
"thirst": round(self.thirst_urgency, 2),
"hunger": round(self.hunger_urgency, 2),
"heat": round(self.heat_urgency, 2),
"energy": round(self.energy_urgency, 2),
},
"inventory": {
"water": self.water_count,
"meat": self.meat_count,
"berries": self.berries_count,
"wood": self.wood_count,
"hide": self.hide_count,
"space": self.inventory_space,
},
"economy": {
"money": self.money,
"is_wealthy": self.is_wealthy,
},
"memory": {
"failed_hunts": self.failed_hunts,
"failed_gathers": self.failed_gathers,
"failed_trades": self.failed_trades,
"memory_count": len(self.memories),
},
}