diff --git a/backend/api/routes.py b/backend/api/routes.py index b193444..c7870b6 100644 --- a/backend/api/routes.py +++ b/backend/api/routes.py @@ -93,12 +93,12 @@ def get_market_prices(): ) def initialize_simulation(request: InitializeRequest = None): """Initialize or reset the simulation. - + If request is provided with specific values, use those. Otherwise, use values from config.json. """ engine = get_engine() - + if request and (request.num_agents != 8 or request.world_width != 20 or request.world_height != 20): # Custom values provided - use them from backend.core.world import WorldConfig @@ -113,7 +113,7 @@ def initialize_simulation(request: InitializeRequest = None): # Use values from config.json engine.reset() # This now loads from config.json automatically num_agents = engine.world.config.initial_agents - + return ControlResponse( success=True, message=f"Simulation initialized with {num_agents} agents", @@ -131,21 +131,21 @@ def initialize_simulation(request: InitializeRequest = None): def next_step(): """Advance the simulation by one step.""" engine = get_engine() - + if engine.mode == SimulationMode.AUTO: raise HTTPException( status_code=400, detail="Cannot manually advance while in AUTO mode. Switch to MANUAL first.", ) - + if not engine.is_running: raise HTTPException( status_code=400, detail="Simulation is not running. Initialize first.", ) - + turn_log = engine.next_step() - + return ControlResponse( success=True, message=f"Advanced to turn {engine.world.current_turn}", @@ -163,7 +163,7 @@ def next_step(): def set_mode(request: SetModeRequest): """Set the simulation mode.""" engine = get_engine() - + try: mode = SimulationMode(request.mode) engine.set_mode(mode) @@ -223,11 +223,11 @@ def get_logs(limit: int = 10): def get_turn_log(turn: int): """Get log for a specific turn.""" engine = get_engine() - + for log in engine.turn_logs: if log.turn == turn: return log.to_dict() - + raise HTTPException(status_code=404, detail=f"Log for turn {turn} not found") @@ -315,3 +315,107 @@ def load_config_from_file(): except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to load config: {str(e)}") + +# ============== GOAP Debug Endpoints ============== + +@router.get( + "/goap/debug/{agent_id}", + summary="Get GOAP debug info for an agent", + description="Returns detailed GOAP decision-making info including goals, actions, and plans.", +) +def get_agent_goap_debug(agent_id: str): + """Get GOAP debug information for a specific agent.""" + engine = get_engine() + agent = engine.world.get_agent(agent_id) + + if agent is None: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + + if not agent.is_alive(): + raise HTTPException(status_code=400, detail=f"Agent {agent_id} is not alive") + + from backend.core.goap.debug import get_goap_debug_info + + debug_info = get_goap_debug_info( + agent=agent, + market=engine.market, + step_in_day=engine.world.step_in_day, + day_steps=engine.world.config.day_steps, + is_night=engine.world.is_night(), + ) + + return debug_info.to_dict() + + +@router.get( + "/goap/debug", + summary="Get GOAP debug info for all agents", + description="Returns GOAP decision-making info for all living agents.", +) +def get_all_goap_debug(): + """Get GOAP debug information for all living agents.""" + engine = get_engine() + + from backend.core.goap.debug import get_all_agents_goap_debug + + debug_infos = get_all_agents_goap_debug( + agents=engine.world.agents, + market=engine.market, + step_in_day=engine.world.step_in_day, + day_steps=engine.world.config.day_steps, + is_night=engine.world.is_night(), + ) + + return { + "agents": [info.to_dict() for info in debug_infos], + "count": len(debug_infos), + "current_turn": engine.world.current_turn, + "is_night": engine.world.is_night(), + } + + +@router.get( + "/goap/goals", + summary="Get all GOAP goals", + description="Returns a list of all available GOAP goals.", +) +def get_goap_goals(): + """Get all available GOAP goals.""" + from backend.core.goap import get_all_goals + + goals = get_all_goals() + return { + "goals": [ + { + "name": g.name, + "type": g.goal_type.value, + "max_plan_depth": g.max_plan_depth, + } + for g in goals + ], + "count": len(goals), + } + + +@router.get( + "/goap/actions", + summary="Get all GOAP actions", + description="Returns a list of all available GOAP actions.", +) +def get_goap_actions(): + """Get all available GOAP actions.""" + from backend.core.goap import get_all_actions + + actions = get_all_actions() + return { + "actions": [ + { + "name": a.name, + "action_type": a.action_type.value, + "target_resource": a.target_resource.value if a.target_resource else None, + } + for a in actions + ], + "count": len(actions), + } + diff --git a/backend/config.py b/backend/config.py index 424ebaf..ee4e02c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -14,19 +14,19 @@ class AgentStatsConfig: max_hunger: int = 100 max_thirst: int = 100 # Increased from 50 to give more buffer max_heat: int = 100 - + # Starting values start_energy: int = 50 start_hunger: int = 80 start_thirst: int = 80 # Increased from 40 to start with more buffer start_heat: int = 100 - + # Decay rates per turn energy_decay: int = 2 hunger_decay: int = 2 thirst_decay: int = 2 # Reduced from 3 to match hunger decay rate heat_decay: int = 2 - + # Thresholds critical_threshold: float = 0.25 # 25% triggers survival mode low_energy_threshold: int = 15 # Minimum energy to work @@ -39,7 +39,7 @@ class ResourceConfig: meat_decay: int = 8 # Increased from 5 to give more time to use berries_decay: int = 25 clothes_decay: int = 50 - + # Resource effects meat_hunger: int = 30 meat_energy: int = 5 @@ -62,11 +62,11 @@ class ActionConfig: weave_energy: int = -8 build_fire_energy: int = -5 trade_energy: int = -1 - + # Success chances (0.0 to 1.0) hunt_success: float = 0.7 chop_wood_success: float = 0.9 - + # Output quantities hunt_meat_min: int = 1 hunt_meat_max: int = 3 @@ -86,7 +86,7 @@ class WorldConfig: initial_agents: int = 8 day_steps: int = 10 night_steps: int = 1 - + # Agent configuration inventory_slots: int = 10 starting_money: int = 100 @@ -103,31 +103,54 @@ class MarketConfig: @dataclass class EconomyConfig: """Configuration for economic behavior and agent trading. - + These values control how agents perceive the value of money and trading. Higher values make agents more trade-oriented. """ # How much agents value money vs energy # Higher = agents see money as more valuable (trade more) - energy_to_money_ratio: float = 1.5 # 1 energy ≈ 1.5 coins - + energy_to_money_ratio: float = 150 # 1 energy ≈ 150 coins + + # Minimum price floor for any market transaction + min_price: int = 100 + # How strongly agents desire wealth (0-1) # Higher = agents will prioritize building wealth wealth_desire: float = 0.3 - + # Buy efficiency threshold (0-1) # If market price < (threshold * fair_value), buy instead of gather # 0.7 means: buy if price is 70% or less of the fair value buy_efficiency_threshold: float = 0.7 - + # Minimum wealth target - agents want at least this much money - min_wealth_target: int = 50 - + min_wealth_target: int = 5000 + # Price adjustment limits max_price_markup: float = 2.0 # Maximum price = 2x base value min_price_discount: float = 0.5 # Minimum price = 50% of base value +@dataclass +class AIConfig: + """Configuration for AI decision-making system. + + Controls whether to use GOAP (Goal-Oriented Action Planning) or + the legacy priority-based system. + """ + # Use GOAP-based AI (True) or legacy priority-based AI (False) + use_goap: bool = True + + # Maximum A* iterations for GOAP planner + goap_max_iterations: int = 50 + + # Maximum plan depth (number of actions in a plan) + goap_max_plan_depth: int = 3 + + # Fall back to reactive planning if GOAP fails to find a plan + reactive_fallback: bool = True + + @dataclass class SimulationConfig: """Master configuration containing all sub-configs.""" @@ -137,13 +160,15 @@ class SimulationConfig: world: WorldConfig = field(default_factory=WorldConfig) market: MarketConfig = field(default_factory=MarketConfig) economy: EconomyConfig = field(default_factory=EconomyConfig) - + ai: AIConfig = field(default_factory=AIConfig) + # Simulation control auto_step_interval: float = 1.0 # Seconds between auto steps - + def to_dict(self) -> dict: """Convert to dictionary.""" return { + "ai": asdict(self.ai), "agent_stats": asdict(self.agent_stats), "resources": asdict(self.resources), "actions": asdict(self.actions), @@ -152,11 +177,12 @@ class SimulationConfig: "economy": asdict(self.economy), "auto_step_interval": self.auto_step_interval, } - + @classmethod def from_dict(cls, data: dict) -> "SimulationConfig": """Create from dictionary.""" return cls( + ai=AIConfig(**data.get("ai", {})), agent_stats=AgentStatsConfig(**data.get("agent_stats", {})), resources=ResourceConfig(**data.get("resources", {})), actions=ActionConfig(**data.get("actions", {})), @@ -165,12 +191,12 @@ class SimulationConfig: economy=EconomyConfig(**data.get("economy", {})), auto_step_interval=data.get("auto_step_interval", 1.0), ) - + def save(self, path: str = "config.json") -> None: """Save configuration to JSON file.""" with open(path, "w") as f: json.dump(self.to_dict(), f, indent=2) - + @classmethod def load(cls, path: str = "config.json") -> "SimulationConfig": """Load configuration from JSON file.""" @@ -188,7 +214,7 @@ _config: Optional[SimulationConfig] = None def get_config() -> SimulationConfig: """Get the global configuration instance. - + Loads from config.json if not already loaded. """ global _config @@ -246,7 +272,7 @@ def _reset_all_caches() -> None: reset_action_config_cache() except ImportError: pass - + try: from backend.domain.resources import reset_resource_cache reset_resource_cache() diff --git a/backend/core/ai.py b/backend/core/ai.py index e714b48..e13e204 100644 --- a/backend/core/ai.py +++ b/backend/core/ai.py @@ -1,16 +1,21 @@ """AI decision system for agents in the Village Simulation. -Major rework to create diverse, personality-driven economy: +This module provides two AI systems: +1. GOAP (Goal-Oriented Action Planning) - The default, modern approach +2. Legacy priority-based system - Kept for comparison/fallback + +GOAP Benefits: +- Agents plan multi-step sequences to achieve goals +- Goals are dynamically prioritized based on state +- More emergent and adaptive behavior +- Easier to extend with new goals and actions + +Major features: - Each agent has unique personality traits affecting all decisions - Emergent professions: Hunters, Gatherers, Traders, Generalists - Class inequality through varied strategies and skills -- Traders focus on arbitrage (buy low, sell high) +- Traders focus on arbitrage (buy low, sell high) - Personality affects: risk tolerance, hoarding, market participation - -Key insight: Different personalities lead to different strategies. -Traders don't gather - they profit from others' labor. -Hunters take risks for bigger rewards. -Gatherers play it safe. """ import random @@ -49,7 +54,7 @@ class AIDecision: # For price adjustments adjust_order_id: Optional[str] = None new_price: Optional[int] = None - + def to_dict(self) -> dict: return { "action": self.action.value, @@ -112,9 +117,9 @@ def _get_economy_config(): class AgentAI: """AI decision maker with personality-driven economy behavior. - + Core philosophy: Each agent has a unique strategy based on personality. - + Personality effects: 1. wealth_desire: How aggressively to accumulate money 2. hoarding_rate: How much to keep vs. sell on market @@ -122,106 +127,114 @@ class AgentAI: 4. market_affinity: How often to engage with market 5. trade_preference: High = trader profession (arbitrage focus) 6. price_sensitivity: How picky about deals - + Emergent professions: - Traders: High trade_preference + market_affinity = buy low, sell high - Hunters: High hunt_preference + risk_tolerance = meat production - Gatherers: High gather_preference, low risk = safe resource collection - Generalists: Balanced approach to all activities """ - + # Thresholds for stat management LOW_THRESHOLD = 0.45 # 45% - proactive action trigger COMFORT_THRESHOLD = 0.60 # 60% - aim for comfort - + # Energy thresholds REST_ENERGY_THRESHOLD = 18 # Rest when below this if no urgent needs WORK_ENERGY_MINIMUM = 20 # Prefer to have this much for work - + # Resource stockpile targets (modified by personality.hoarding_rate) BASE_WATER_STOCK = 2 BASE_FOOD_STOCK = 3 BASE_WOOD_STOCK = 2 - + # Heat thresholds HEAT_PROACTIVE_THRESHOLD = 0.50 - - # Base economy settings (modified by personality) - ENERGY_TO_MONEY_RATIO = 1.5 # 1 energy = ~1.5 coins in perceived value + + # Base economy settings (loaded from config, modified by personality) + # These are default fallbacks; actual values come from config MAX_PRICE_MARKUP = 2.0 # Maximum markup over base price MIN_PRICE_DISCOUNT = 0.5 # Minimum discount (50% of base price) - + def __init__(self, agent: Agent, market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0): self.agent = agent self.market = market self.step_in_day = step_in_day self.day_steps = day_steps self.current_turn = current_turn - + # Personality shortcuts self.p = agent.personality # Convenience shortcut self.skills = agent.skills - + # Load thresholds from config config = _get_ai_config() self.CRITICAL_THRESHOLD = config.critical_threshold self.LOW_ENERGY_THRESHOLD = config.low_energy_threshold - + # Personality-adjusted values # Wealth desire from personality (0.1 to 0.9) self.WEALTH_DESIRE = self.p.wealth_desire - + + # Load economy config + economy = _get_economy_config() + + # Energy to money ratio (how much 1 energy is worth in coins) + self.ENERGY_TO_MONEY_RATIO = getattr(economy, 'energy_to_money_ratio', 150) if economy else 150 + + # Minimum price floor + self.MIN_PRICE = getattr(economy, 'min_price', 100) if economy else 100 + # Buy efficiency threshold adjusted by price sensitivity # High sensitivity = only buy very good deals - economy = _get_economy_config() base_threshold = getattr(economy, 'buy_efficiency_threshold', 0.7) if economy else 0.7 self.BUY_EFFICIENCY_THRESHOLD = base_threshold / self.p.price_sensitivity - + # Wealth target scaled by wealth desire - base_target = getattr(economy, 'min_wealth_target', 50) if economy else 50 + base_target = getattr(economy, 'min_wealth_target', 5000) if economy else 5000 self.MIN_WEALTH_TARGET = int(base_target * (0.5 + self.p.wealth_desire)) - + # Resource stockpile targets modified by hoarding rate # High hoarders keep more in reserve hoarding_mult = 0.5 + self.p.hoarding_rate # 0.6 to 1.4 self.MIN_WATER_STOCK = max(1, int(self.BASE_WATER_STOCK * hoarding_mult)) self.MIN_FOOD_STOCK = max(2, int(self.BASE_FOOD_STOCK * hoarding_mult)) self.MIN_WOOD_STOCK = max(1, int(self.BASE_WOOD_STOCK * hoarding_mult)) - + # Trader mode: agents with high trade preference become market-focused self.is_trader = self.p.trade_preference > 1.3 and self.p.market_affinity > 0.5 - + @property def is_evening(self) -> bool: """Check if it's getting close to night (last 2 day steps).""" return self.step_in_day >= self.day_steps - 1 - + @property def is_late_day(self) -> bool: """Check if it's past midday (preparation time).""" return self.step_in_day >= self.day_steps // 2 - + def _get_resource_fair_value(self, resource_type: ResourceType) -> int: """Calculate the 'fair value' of a resource based on energy cost to produce. - + This is the theoretical minimum price an agent should sell for, and the maximum they should pay before just gathering themselves. """ energy_cost = get_energy_cost(resource_type) - return max(1, int(energy_cost * self.ENERGY_TO_MONEY_RATIO)) - + return max(self.MIN_PRICE, int(energy_cost * self.ENERGY_TO_MONEY_RATIO)) + def _is_good_buy(self, resource_type: ResourceType, price: int) -> bool: """Check if a market price is a good deal (cheaper than gathering).""" fair_value = self._get_resource_fair_value(resource_type) return price <= fair_value * self.BUY_EFFICIENCY_THRESHOLD - + def _is_wealthy(self) -> bool: """Check if agent has comfortable wealth.""" return self.agent.money >= self.MIN_WEALTH_TARGET - + def decide(self) -> AIDecision: """Make a decision based on survival, personality, and economic goals. - + Decision flow varies by personality: - Traders prioritize market operations (arbitrage) - Hunters prefer hunting when possible @@ -232,54 +245,54 @@ class AgentAI: decision = self._check_critical_needs() if decision: return decision - + # Priority 2: Proactive survival (prevent problems before they happen) decision = self._check_proactive_needs() if decision: return decision - + # Priority 3: Trader-specific behavior (high trade_preference agents) # Traders focus on market operations when survival is secured if self.is_trader: decision = self._do_trader_behavior() if decision: return decision - + # Priority 4: Price adjustment - respond to market conditions decision = self._check_price_adjustments() if decision: return decision - + # Priority 5: Smart shopping - buy good deals on the market! # Frequency affected by market_affinity if random.random() < self.p.market_affinity: decision = self._check_market_opportunities() if decision: return decision - + # Priority 6: Craft clothes if we have hide decision = self._check_clothes_crafting() if decision: return decision - + # Priority 7: Energy management decision = self._check_energy() if decision: return decision - + # Priority 8: Economic activities (sell excess, build wealth) # Frequency affected by hoarding_rate (low hoarding = sell more) if random.random() > self.p.hoarding_rate * 0.5: decision = self._check_economic() if decision: return decision - + # Priority 9: Routine survival work (gather resources we need) return self._do_survival_work() - + def _do_trader_behavior(self) -> Optional[AIDecision]: """Trader-specific behavior: focus on arbitrage and market operations. - + Traders don't gather much - they profit from buying low and selling high. Core trader strategy: 1. Look for arbitrage opportunities (price differences) @@ -295,37 +308,37 @@ class AgentAI: return decision # If nothing to sell, do some quick gathering return None # Fall through to normal behavior - + # Look for arbitrage opportunities # Find resources being sold below fair value best_deal = None best_margin = 0 - + for resource_type in [ResourceType.MEAT, ResourceType.BERRIES, ResourceType.WATER, ResourceType.WOOD]: order = self.market.get_cheapest_order(resource_type) if not order or order.seller_id == self.agent.id: continue - + fair_value = self._get_resource_fair_value(resource_type) - + # Check if we can profit by buying and reselling buy_price = order.price_per_unit # Apply trading skill to get better prices sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False) potential_sell_price = int(fair_value * sell_modifier * 1.1) # 10% markup target - + margin = potential_sell_price - buy_price if margin > best_margin and self.agent.money >= buy_price: best_margin = margin best_deal = (resource_type, order, buy_price, potential_sell_price) - + # Execute arbitrage if profitable if best_deal and best_margin >= 2: # At least 2 coins profit resource_type, order, buy_price, sell_price = best_deal safe_price = max(1, buy_price) # Prevent division by zero - quantity = min(3, self.agent.inventory_space(), order.quantity, + quantity = min(3, self.agent.inventory_space(), order.quantity, self.agent.money // safe_price) - + if quantity > 0: return AIDecision( action=ActionType.TRADE, @@ -335,49 +348,49 @@ class AgentAI: price=buy_price, reason=f"Trader: buying {resource_type.value} @ {buy_price}c (resell @ {sell_price}c)", ) - + # If holding inventory, try to sell at markup decision = self._try_trader_sell() if decision: return decision - + # Adjust prices on existing orders decision = self._check_price_adjustments() if decision: return decision - + return None - + def _try_trader_sell(self) -> Optional[AIDecision]: """Trader sells inventory at markup prices.""" for resource in self.agent.inventory: if resource.type == ResourceType.CLOTHES: continue - + # Traders sell everything except minimal survival reserves if resource.quantity <= 1: continue - + fair_value = self._get_resource_fair_value(resource.type) - + # Apply trading skill for better sell prices sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False) - + # Check market conditions signal = self.market.get_market_signal(resource.type) if signal == "sell": # Scarcity - high markup price = int(fair_value * sell_modifier * 1.4) else: price = int(fair_value * sell_modifier * 1.15) - + # Don't undercut ourselves if we have active orders - my_orders = [o for o in self.market.get_orders_by_seller(self.agent.id) + my_orders = [o for o in self.market.get_orders_by_seller(self.agent.id) if o.resource_type == resource.type] if my_orders: continue # Wait for existing order to sell - + quantity = resource.quantity - 1 # Keep 1 for emergencies - + return AIDecision( action=ActionType.TRADE, target_resource=resource.type, @@ -385,53 +398,53 @@ class AgentAI: price=price, reason=f"Trader: selling {resource.type.value} @ {price}c (markup)", ) - + return None - + def _check_critical_needs(self) -> Optional[AIDecision]: """Check if any vital stat is critical and act accordingly.""" stats = self.agent.stats - + # Check thirst first (depletes fastest and kills quickly) if stats.thirst < stats.MAX_THIRST * self.CRITICAL_THRESHOLD: return self._address_thirst(critical=True) - + # Check hunger if stats.hunger < stats.MAX_HUNGER * self.CRITICAL_THRESHOLD: return self._address_hunger(critical=True) - + # Check heat - critical level if stats.heat < stats.MAX_HEAT * self.CRITICAL_THRESHOLD: return self._address_heat(critical=True) - + return None - + def _check_proactive_needs(self) -> Optional[AIDecision]: """Proactively address needs before they become critical. - + IMPORTANT CHANGE: Now considers buying as a first option, not last! """ stats = self.agent.stats - + # Proactive thirst management if stats.thirst < stats.MAX_THIRST * self.LOW_THRESHOLD: return self._address_thirst(critical=False) - + # Proactive hunger management if stats.hunger < stats.MAX_HUNGER * self.LOW_THRESHOLD: return self._address_hunger(critical=False) - + # Proactive heat management if stats.heat < stats.MAX_HEAT * self.HEAT_PROACTIVE_THRESHOLD: decision = self._address_heat(critical=False) if decision: return decision - + return None - + def _check_price_adjustments(self) -> Optional[AIDecision]: """Check if we should adjust prices on our market orders. - + Smart pricing strategy: - If order is stale (not selling), lower price - If demand is high (scarcity), raise price @@ -440,13 +453,13 @@ class AgentAI: my_orders = self.market.get_orders_by_seller(self.agent.id) if not my_orders: return None - + for order in my_orders: resource_type = order.resource_type signal = self.market.get_market_signal(resource_type) current_price = order.price_per_unit fair_value = self._get_resource_fair_value(resource_type) - + # If demand is high and we've waited, consider raising price if signal == "sell" and order.can_raise_price(self.current_turn, min_turns=3): # Scarcity - raise price (but not too high) @@ -462,7 +475,7 @@ class AgentAI: new_price=new_price, reason=f"Scarcity: raising {resource_type.value} price to {new_price}c", ) - + # If order is getting stale (sitting too long), lower price if order.turns_without_sale >= 5: # Calculate competitive price @@ -479,7 +492,7 @@ class AgentAI: int(current_price * 0.85), int(fair_value * self.MIN_PRICE_DISCOUNT) ) - + if new_price < current_price: return AIDecision( action=ActionType.TRADE, @@ -488,15 +501,15 @@ class AgentAI: new_price=new_price, reason=f"Stale order: lowering {resource_type.value} price to {new_price}c", ) - + return None - + def _check_market_opportunities(self) -> Optional[AIDecision]: """Look for good buying opportunities on the market. - + KEY INSIGHT: If market price < energy cost to gather, ALWAYS BUY! This is the core of smart trading behavior. - + Buying is smart because: - Trade costs only 1 energy - Gathering costs 4-8 energy @@ -505,40 +518,40 @@ class AgentAI: # Don't shop if we're low on money and not wealthy if self.agent.money < 10: return None - + # Resources we might want to buy shopping_list = [] - + # Check each resource type for good deals for resource_type in [ResourceType.WATER, ResourceType.BERRIES, ResourceType.MEAT, ResourceType.WOOD]: order = self.market.get_cheapest_order(resource_type) if not order or order.seller_id == self.agent.id: continue - + # Skip invalid orders (price <= 0) if order.price_per_unit <= 0: continue - + if self.agent.money < order.price_per_unit: continue - + # Calculate if this is a good deal fair_value = self._get_resource_fair_value(resource_type) is_good_deal = self._is_good_buy(resource_type, order.price_per_unit) - + # Calculate our current need for this resource current_stock = self.agent.get_resource_count(resource_type) need_score = 0 - + if resource_type == ResourceType.WATER: need_score = max(0, self.MIN_WATER_STOCK - current_stock) * 3 elif resource_type in [ResourceType.BERRIES, ResourceType.MEAT]: - food_stock = (self.agent.get_resource_count(ResourceType.BERRIES) + + food_stock = (self.agent.get_resource_count(ResourceType.BERRIES) + self.agent.get_resource_count(ResourceType.MEAT)) need_score = max(0, self.MIN_FOOD_STOCK - food_stock) * 2 elif resource_type == ResourceType.WOOD: need_score = max(0, self.MIN_WOOD_STOCK - current_stock) * 1 - + # Score this opportunity if is_good_deal: # Good deal - definitely consider buying @@ -550,27 +563,27 @@ class AgentAI: # Not a great deal, but we need it and have money total_score = need_score * 0.5 shopping_list.append((resource_type, order, total_score)) - + if not shopping_list: return None - + # Sort by score and pick the best opportunity shopping_list.sort(key=lambda x: x[2], reverse=True) resource_type, order, score = shopping_list[0] - + # Only act if the opportunity is worth it if score < 1: return None - + # Calculate how much to buy price = max(1, order.price_per_unit) # Prevent division by zero can_afford = self.agent.money // price space = self.agent.inventory_space() want_quantity = min(2, can_afford, space, order.quantity) # Buy up to 2 at a time - + if want_quantity <= 0: return None - + return AIDecision( action=ActionType.TRADE, target_resource=resource_type, @@ -579,42 +592,42 @@ class AgentAI: price=order.price_per_unit, reason=f"Good deal: buying {resource_type.value} @ {order.price_per_unit}c (fair value: {self._get_resource_fair_value(resource_type)}c)", ) - + def _check_clothes_crafting(self) -> Optional[AIDecision]: """Check if we should craft clothes for heat efficiency.""" # Only craft if we don't already have clothes and have hide if self.agent.has_clothes(): return None - + # Need hide to craft if not self.agent.has_resource(ResourceType.HIDE): return None - + # Need energy to craft weave_config = ACTION_CONFIG[ActionType.WEAVE] if not self.agent.stats.can_work(abs(weave_config.energy_cost)): return None - + # Only craft if we're not in survival mode stats = self.agent.stats if (stats.thirst < stats.MAX_THIRST * self.LOW_THRESHOLD or stats.hunger < stats.MAX_HUNGER * self.LOW_THRESHOLD): return None - + return AIDecision( action=ActionType.WEAVE, target_resource=ResourceType.CLOTHES, reason="Crafting clothes for heat protection", ) - + def _address_thirst(self, critical: bool = False) -> AIDecision: """Address thirst - water is the primary solution. - + NEW PRIORITY: Try buying FIRST if it's efficient! Trading uses only 1 energy vs 3 for getting water. """ prefix = "Critical" if critical else "Low" - + # Step 1: Consume water from inventory (best - immediate, free) if self.agent.has_resource(ResourceType.WATER): return AIDecision( @@ -622,21 +635,21 @@ class AgentAI: target_resource=ResourceType.WATER, reason=f"{prefix} thirst: consuming water", ) - + # Step 2: Check if buying is more efficient than gathering # Trade = 1 energy, Get water = 3 energy. If price is reasonable, BUY! water_order = self.market.get_cheapest_order(ResourceType.WATER) if water_order and water_order.seller_id != self.agent.id: if self.agent.money >= water_order.price_per_unit: fair_value = self._get_resource_fair_value(ResourceType.WATER) - + # Buy if: good deal OR critical situation OR we're wealthy should_buy = ( self._is_good_buy(ResourceType.WATER, water_order.price_per_unit) or critical or (self._is_wealthy() and water_order.price_per_unit <= fair_value * 1.5) ) - + if should_buy: return AIDecision( action=ActionType.TRADE, @@ -646,7 +659,7 @@ class AgentAI: price=water_order.price_per_unit, reason=f"{prefix} thirst: buying water @ {water_order.price_per_unit}c", ) - + # Step 3: Get water ourselves water_config = ACTION_CONFIG[ActionType.GET_WATER] if self.agent.stats.can_work(abs(water_config.energy_cost)): @@ -655,7 +668,7 @@ class AgentAI: target_resource=ResourceType.WATER, reason=f"{prefix} thirst: getting water from river", ) - + # Step 4: Emergency - consume berries (gives +3 thirst) if self.agent.has_resource(ResourceType.BERRIES): return AIDecision( @@ -663,21 +676,21 @@ class AgentAI: target_resource=ResourceType.BERRIES, reason=f"{prefix} thirst: consuming berries (emergency)", ) - + # No energy to get water - rest return AIDecision( action=ActionType.REST, reason=f"{prefix} thirst: too tired, resting", ) - + def _address_hunger(self, critical: bool = False) -> AIDecision: """Address hunger - meat is best, berries are backup. - + NEW PRIORITY: Consider buying FIRST if market has good prices! Trading = 1 energy vs 4-7 for gathering/hunting. """ prefix = "Critical" if critical else "Low" - + # Step 1: Consume meat from inventory (best for hunger - +40) if self.agent.has_resource(ResourceType.MEAT): return AIDecision( @@ -685,7 +698,7 @@ class AgentAI: target_resource=ResourceType.MEAT, reason=f"{prefix} hunger: consuming meat", ) - + # Step 2: Consume berries if we have them (+10 hunger) if self.agent.has_resource(ResourceType.BERRIES): return AIDecision( @@ -693,20 +706,20 @@ class AgentAI: target_resource=ResourceType.BERRIES, reason=f"{prefix} hunger: consuming berries", ) - + # Step 3: Check if buying food is more efficient than gathering for resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: order = self.market.get_cheapest_order(resource_type) if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit: fair_value = self._get_resource_fair_value(resource_type) - + # Buy if: good deal OR critical OR wealthy should_buy = ( self._is_good_buy(resource_type, order.price_per_unit) or critical or (self._is_wealthy() and order.price_per_unit <= fair_value * 1.5) ) - + if should_buy: return AIDecision( action=ActionType.TRADE, @@ -716,7 +729,7 @@ class AgentAI: price=order.price_per_unit, reason=f"{prefix} hunger: buying {resource_type.value} @ {order.price_per_unit}c", ) - + # Step 4: Gather berries ourselves (easy, 100% success) gather_config = ACTION_CONFIG[ActionType.GATHER] if self.agent.stats.can_work(abs(gather_config.energy_cost)): @@ -725,21 +738,21 @@ class AgentAI: target_resource=ResourceType.BERRIES, reason=f"{prefix} hunger: gathering berries", ) - + # No energy - rest return AIDecision( action=ActionType.REST, reason=f"{prefix} hunger: too tired, resting", ) - + def _address_heat(self, critical: bool = False) -> Optional[AIDecision]: """Address heat by building fire or getting wood. - + NOW: Always considers buying wood if it's a good deal! Trade = 1 energy vs 8 energy for chopping. """ prefix = "Critical" if critical else "Low" - + # Step 1: Build fire if we have wood if self.agent.has_resource(ResourceType.WOOD): fire_config = ACTION_CONFIG[ActionType.BUILD_FIRE] @@ -749,19 +762,19 @@ class AgentAI: target_resource=ResourceType.WOOD, reason=f"{prefix} heat: building fire", ) - + # Step 2: Buy wood if available and efficient cheapest = self.market.get_cheapest_order(ResourceType.WOOD) if cheapest and cheapest.seller_id != self.agent.id and self.agent.money >= cheapest.price_per_unit: fair_value = self._get_resource_fair_value(ResourceType.WOOD) - + # Buy if: good deal OR critical OR wealthy should_buy = ( self._is_good_buy(ResourceType.WOOD, cheapest.price_per_unit) or critical or (self._is_wealthy() and cheapest.price_per_unit <= fair_value * 1.5) ) - + if should_buy: return AIDecision( action=ActionType.TRADE, @@ -771,7 +784,7 @@ class AgentAI: price=cheapest.price_per_unit, reason=f"{prefix} heat: buying wood @ {cheapest.price_per_unit}c", ) - + # Step 3: Chop wood ourselves chop_config = ACTION_CONFIG[ActionType.CHOP_WOOD] if self.agent.stats.can_work(abs(chop_config.energy_cost)): @@ -780,31 +793,31 @@ class AgentAI: target_resource=ResourceType.WOOD, reason=f"{prefix} heat: chopping wood for fire", ) - + # If not critical, return None to let other priorities take over if not critical: return None - + return AIDecision( action=ActionType.REST, reason=f"{prefix} heat: too tired to get wood, resting", ) - + def _check_energy(self) -> Optional[AIDecision]: """Check if energy management is needed. - + Improved logic: Don't rest at 13-14 energy just to rest. Instead, rest only if we truly can't do essential work. """ stats = self.agent.stats - + # Only rest if energy is very low if stats.energy < self.LOW_ENERGY_THRESHOLD: return AIDecision( action=ActionType.REST, reason=f"Energy critically low ({stats.energy}), must rest", ) - + # If it's evening and energy is moderate, rest to prepare for night if self.is_evening and stats.energy < self.REST_ENERGY_THRESHOLD: # Only if we have enough supplies @@ -818,12 +831,12 @@ class AgentAI: action=ActionType.REST, reason=f"Evening: resting to prepare for night", ) - + return None - + def _check_economic(self) -> Optional[AIDecision]: """Economic activities: selling, wealth building, market participation. - + NEW PHILOSOPHY: Actively participate in the market! - Sell excess resources to build wealth - Price based on supply/demand, not just to clear inventory @@ -834,18 +847,18 @@ class AgentAI: decision = self._try_proactive_sell() if decision: return decision - + # If inventory is getting full, must sell if self.agent.inventory_space() <= 2: decision = self._try_to_sell(urgent=True) if decision: return decision - + return None - + def _try_proactive_sell(self) -> Optional[AIDecision]: """Proactively sell when market conditions are good. - + Affected by personality: - hoarding_rate: High hoarders keep more, sell less - wealth_desire: High wealth desire = more aggressive selling @@ -854,11 +867,11 @@ class AgentAI: if self._is_wealthy() and self.agent.inventory_space() > 3: # Already rich and have space, no rush to sell return None - + # Hoarders are reluctant to sell if random.random() < self.p.hoarding_rate * 0.7: return None - + # Survival minimums scaled by hoarding rate base_min = { ResourceType.WATER: 2, @@ -867,32 +880,32 @@ class AgentAI: ResourceType.WOOD: 2, ResourceType.HIDE: 0, } - + # High hoarders keep more hoarding_mult = 0.5 + self.p.hoarding_rate survival_minimums = {k: int(v * hoarding_mult) for k, v in base_min.items()} - + # Look for profitable sales best_opportunity = None best_score = 0 - + for resource in self.agent.inventory: if resource.type == ResourceType.CLOTHES: continue # Don't sell clothes - + min_keep = survival_minimums.get(resource.type, 1) excess = resource.quantity - min_keep - + if excess <= 0: continue - + # Check market conditions signal = self.market.get_market_signal(resource.type) fair_value = self._get_resource_fair_value(resource.type) - + # Apply trading skill for better sell prices sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False) - + # Calculate optimal price if signal == "sell": # Scarcity - we can charge more price = int(fair_value * 1.3 * sell_modifier) @@ -904,18 +917,18 @@ class AgentAI: # Find cheapest competitor cheapest = self.market.get_cheapest_order(resource.type) if cheapest and cheapest.seller_id != self.agent.id: - price = max(1, cheapest.price_per_unit - 1) + price = max(self.MIN_PRICE, cheapest.price_per_unit - 1) else: price = int(fair_value * 0.8 * sell_modifier) score = 0.5 # Not a great time to sell - + # Wealth desire increases sell motivation score *= (0.7 + self.p.wealth_desire * 0.6) - + if score > best_score: best_score = score best_opportunity = (resource.type, min(excess, 3), price) # Sell up to 3 at a time - + if best_opportunity and best_score >= 1: resource_type, quantity, price = best_opportunity return AIDecision( @@ -925,9 +938,9 @@ class AgentAI: price=price, reason=f"Selling {resource_type.value} @ {price}c", ) - + return None - + def _try_to_sell(self, urgent: bool = False) -> Optional[AIDecision]: """Sell excess resources, keeping enough for survival.""" survival_minimums = { @@ -937,11 +950,11 @@ class AgentAI: ResourceType.WOOD: 1 if urgent else 2, ResourceType.HIDE: 0, } - + for resource in self.agent.inventory: if resource.type == ResourceType.CLOTHES: continue - + min_keep = survival_minimums.get(resource.type, 1) if resource.quantity > min_keep: quantity_to_sell = resource.quantity - min_keep @@ -955,14 +968,14 @@ class AgentAI: reason=reason, ) return None - + def _calculate_sell_price(self, resource_type: ResourceType) -> int: """Calculate sell price based on fair value and market conditions.""" fair_value = self._get_resource_fair_value(resource_type) - + # Get market suggestion suggested = self.market.get_suggested_price(resource_type, fair_value) - + # Check competition cheapest = self.market.get_cheapest_order(resource_type) if cheapest and cheapest.seller_id != self.agent.id: @@ -971,12 +984,12 @@ class AgentAI: if signal != "sell": # Match or undercut suggested = min(suggested, cheapest.price_per_unit) - - return max(1, suggested) - + + return max(self.MIN_PRICE, suggested) + def _do_survival_work(self) -> AIDecision: """Perform work based on survival needs AND personality preferences. - + Personality effects: - hunt_preference: Likelihood of choosing to hunt - gather_preference: Likelihood of choosing to gather @@ -984,17 +997,17 @@ class AgentAI: - market_affinity: Likelihood of buying vs gathering """ stats = self.agent.stats - + # Count current resources water_count = self.agent.get_resource_count(ResourceType.WATER) meat_count = self.agent.get_resource_count(ResourceType.MEAT) berry_count = self.agent.get_resource_count(ResourceType.BERRIES) wood_count = self.agent.get_resource_count(ResourceType.WOOD) food_count = meat_count + berry_count - + # Urgency calculations heat_urgency = 1 - (stats.heat / stats.MAX_HEAT) - + # Helper to decide: buy or gather? def get_resource_decision(resource_type: ResourceType, gather_action: ActionType, reason: str) -> AIDecision: """Decide whether to buy or gather a resource.""" @@ -1005,7 +1018,7 @@ class AgentAI: # Apply trading skill for better buy prices buy_modifier = get_trade_price_modifier(self.skills.trading, is_buying=True) effective_price = order.price_per_unit # Skill affects perceived value - + if self._is_good_buy(resource_type, effective_price): return AIDecision( action=ActionType.TRADE, @@ -1015,7 +1028,7 @@ class AgentAI: price=order.price_per_unit, reason=f"{reason} (buying @ {order.price_per_unit}c)", ) - + # Gather it ourselves config = ACTION_CONFIG[gather_action] if self.agent.stats.can_work(abs(config.energy_cost)): @@ -1025,17 +1038,17 @@ class AgentAI: reason=f"{reason} (gathering)", ) return None - + # Priority: Stock up on water if low if water_count < self.MIN_WATER_STOCK: decision = get_resource_decision( - ResourceType.WATER, - ActionType.GET_WATER, + ResourceType.WATER, + ActionType.GET_WATER, f"Stocking water ({water_count} < {self.MIN_WATER_STOCK})" ) if decision: return decision - + # Priority: Stock up on wood if low (for heat) # Affected by woodcut_preference if wood_count < self.MIN_WOOD_STOCK and heat_urgency > 0.3: @@ -1046,30 +1059,30 @@ class AgentAI: ) if decision: return decision - + # Priority: Stock up on food if low if food_count < self.MIN_FOOD_STOCK: hunt_config = ACTION_CONFIG[ActionType.HUNT] - + # Personality-driven choice between hunting and gathering # risk_tolerance and hunt_preference affect this choice can_hunt = stats.energy >= abs(hunt_config.energy_cost) + 5 - + # Calculate hunt probability based on personality # High risk_tolerance + high hunt_preference = more hunting hunt_score = self.p.hunt_preference * self.p.risk_tolerance gather_score = self.p.gather_preference * (1.5 - self.p.risk_tolerance) - + # Normalize to probability total = hunt_score + gather_score hunt_prob = hunt_score / total if total > 0 else 0.3 - + # Also prefer hunting if we have no meat if meat_count == 0: hunt_prob = min(0.8, hunt_prob + 0.3) - + prefer_hunt = can_hunt and random.random() < hunt_prob - + if prefer_hunt: decision = get_resource_decision( ResourceType.MEAT, @@ -1078,7 +1091,7 @@ class AgentAI: ) if decision: return decision - + # Otherwise try berries decision = get_resource_decision( ResourceType.BERRIES, @@ -1087,7 +1100,7 @@ class AgentAI: ) if decision: return decision - + # Evening preparation if self.is_late_day: if water_count < self.MIN_WATER_STOCK + 1: @@ -1102,35 +1115,35 @@ class AgentAI: decision = get_resource_decision(ResourceType.WOOD, ActionType.CHOP_WOOD, "Evening: stocking wood") if decision: return decision - + # Default: varied work based on need AND personality preferences needs = [] - + if water_count < self.MIN_WATER_STOCK + 2: needs.append((ResourceType.WATER, ActionType.GET_WATER, "Getting water", 2.0)) - + if food_count < self.MIN_FOOD_STOCK + 2: # Weight by personality preferences - needs.append((ResourceType.BERRIES, ActionType.GATHER, "Gathering berries", + needs.append((ResourceType.BERRIES, ActionType.GATHER, "Gathering berries", 2.0 * self.p.gather_preference)) - + # Add hunting weighted by personality hunt_config = ACTION_CONFIG[ActionType.HUNT] if stats.energy >= abs(hunt_config.energy_cost) + 3: hunt_weight = 2.0 * self.p.hunt_preference * self.p.risk_tolerance needs.append((ResourceType.MEAT, ActionType.HUNT, "Hunting for meat", hunt_weight)) - + if wood_count < self.MIN_WOOD_STOCK + 2: - needs.append((ResourceType.WOOD, ActionType.CHOP_WOOD, "Chopping wood", + needs.append((ResourceType.WOOD, ActionType.CHOP_WOOD, "Chopping wood", 1.0 * self.p.woodcut_preference)) - + if not needs: # We have good reserves, maybe sell excess or rest if self.agent.inventory_space() <= 4: decision = self._try_proactive_sell() if decision: return decision - + # Default activity based on personality # High hunt_preference = hunt, else gather if self.p.hunt_preference > self.p.gather_preference and stats.energy >= 10: @@ -1144,7 +1157,7 @@ class AgentAI: target_resource=ResourceType.BERRIES, reason="Default: gathering (personality)", ) - + # For each need, check if we can buy cheaply (market_affinity affects this) if random.random() < self.p.market_affinity: for resource_type, action, reason, weight in needs: @@ -1159,7 +1172,7 @@ class AgentAI: price=order.price_per_unit, reason=f"{reason} (buying cheap!)", ) - + # Weighted random selection for gathering total_weight = sum(weight for _, _, _, weight in needs) r = random.random() * total_weight @@ -1172,7 +1185,7 @@ class AgentAI: target_resource=resource, reason=reason, ) - + # Fallback resource, action, reason, _ = needs[0] return AIDecision( @@ -1182,7 +1195,58 @@ class AgentAI: ) -def get_ai_decision(agent: Agent, market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0) -> AIDecision: - """Convenience function to get an AI decision for an agent.""" +def get_ai_decision( + agent: Agent, + market: "OrderBook", + step_in_day: int = 1, + day_steps: int = 10, + current_turn: int = 0, + use_goap: bool = True, + is_night: bool = False, +) -> AIDecision: + """Get an AI decision for an agent. + + By default, uses the GOAP (Goal-Oriented Action Planning) system. + Set use_goap=False to use the legacy priority-based system. + + Args: + agent: The agent to make a decision for + market: The market order book + step_in_day: Current step within the day + day_steps: Total steps per day + current_turn: Current simulation turn + use_goap: Whether to use GOAP (default True) or legacy system + is_night: Whether it's currently night time + + Returns: + AIDecision with the chosen action and parameters + """ + if use_goap: + from backend.core.goap.goap_ai import get_goap_decision + return get_goap_decision( + agent=agent, + market=market, + step_in_day=step_in_day, + day_steps=day_steps, + current_turn=current_turn, + is_night=is_night, + ) + else: + # Legacy priority-based system + ai = AgentAI(agent, market, step_in_day, day_steps, current_turn) + return ai.decide() + + +def get_legacy_ai_decision( + agent: Agent, + market: "OrderBook", + step_in_day: int = 1, + day_steps: int = 10, + current_turn: int = 0, +) -> AIDecision: + """Get an AI decision using the legacy priority-based system. + + This is kept for comparison and testing purposes. + """ ai = AgentAI(agent, market, step_in_day, day_steps, current_turn) return ai.decide() diff --git a/backend/core/engine.py b/backend/core/engine.py index df7b892..d50711a 100644 --- a/backend/core/engine.py +++ b/backend/core/engine.py @@ -171,21 +171,19 @@ class GameEngine: money=agent.money, ) - if self.world.is_night(): - # Force sleep at night - decision = AIDecision( - action=ActionType.SLEEP, - reason="Night time: sleeping", - ) - else: - # Pass time info so AI can prepare for night - decision = get_ai_decision( - agent, - self.market, - step_in_day=self.world.step_in_day, - day_steps=self.world.config.day_steps, - current_turn=current_turn, - ) + # Get AI config to determine which system to use + ai_config = get_config().ai + + # GOAP AI handles night time automatically + decision = get_ai_decision( + agent, + self.market, + step_in_day=self.world.step_in_day, + day_steps=self.world.config.day_steps, + current_turn=current_turn, + use_goap=ai_config.use_goap, + is_night=self.world.is_night(), + ) decisions.append((agent, decision)) diff --git a/backend/core/goap/__init__.py b/backend/core/goap/__init__.py new file mode 100644 index 0000000..efebbd7 --- /dev/null +++ b/backend/core/goap/__init__.py @@ -0,0 +1,39 @@ +"""GOAP (Goal-Oriented Action Planning) module for agent decision making. + +This module provides a GOAP-based AI system where agents: +1. Evaluate their current world state +2. Select the most relevant goal based on priorities +3. Plan a sequence of actions to achieve that goal +4. Execute the first action in the plan + +Key components: +- WorldState: Dictionary-like representation of agent/world state +- Goal: Goals with dynamic priority calculation +- GOAPAction: Actions with preconditions and effects +- Planner: A* search for finding optimal action sequences +""" + +from .world_state import WorldState +from .goal import Goal, GoalType +from .action import GOAPAction +from .planner import GOAPPlanner +from .goals import SURVIVAL_GOALS, ECONOMIC_GOALS, get_all_goals +from .actions import get_all_actions, get_action_by_type +from .debug import GOAPDebugInfo, get_goap_debug_info, get_all_agents_goap_debug + +__all__ = [ + 'WorldState', + 'Goal', + 'GoalType', + 'GOAPAction', + 'GOAPPlanner', + 'SURVIVAL_GOALS', + 'ECONOMIC_GOALS', + 'get_all_goals', + 'get_all_actions', + 'get_action_by_type', + 'GOAPDebugInfo', + 'get_goap_debug_info', + 'get_all_agents_goap_debug', +] + diff --git a/backend/core/goap/action.py b/backend/core/goap/action.py new file mode 100644 index 0000000..2429cb2 --- /dev/null +++ b/backend/core/goap/action.py @@ -0,0 +1,381 @@ +"""GOAP Action definitions. + +Actions are the building blocks of plans. Each action has: +- Preconditions: What must be true for the action to be valid +- Effects: How the action changes the world state +- Cost: How expensive the action is (for planning) +""" + +from dataclasses import dataclass, field +from typing import Callable, Optional, TYPE_CHECKING + +from backend.domain.action import ActionType +from backend.domain.resources import ResourceType + +from .world_state import WorldState + +if TYPE_CHECKING: + from backend.domain.agent import Agent + from backend.core.market import OrderBook + + +@dataclass +class GOAPAction: + """A GOAP action that can be part of a plan. + + Actions transform the world state. The planner uses preconditions + and effects to search for valid action sequences. + + Attributes: + name: Human-readable name + action_type: The underlying ActionType to execute + target_resource: Optional resource this action targets + preconditions: Function that checks if action is valid in a state + effects: Function that returns the expected effects on state + cost: Function that calculates action cost (lower = preferred) + get_decision_params: Function to get parameters for AIDecision + """ + name: str + action_type: ActionType + target_resource: Optional[ResourceType] = None + + # Functions that evaluate in context of world state + preconditions: Callable[[WorldState], bool] = field(default=lambda s: True) + effects: Callable[[WorldState], dict] = field(default=lambda s: {}) + cost: Callable[[WorldState], float] = field(default=lambda s: 1.0) + + # For generating the actual decision + get_decision_params: Optional[Callable[[WorldState, "Agent", "OrderBook"], dict]] = None + + def is_valid(self, state: WorldState) -> bool: + """Check if this action can be performed in the given state.""" + return self.preconditions(state) + + def apply(self, state: WorldState) -> WorldState: + """Apply this action's effects to a state, returning a new state. + + This is used by the planner for forward search. + """ + new_state = state.copy() + effects = self.effects(state) + + for key, value in effects.items(): + if hasattr(new_state, key): + if isinstance(value, (int, float)): + # For numeric values, handle both absolute and relative changes + current = getattr(new_state, key) + if isinstance(current, bool): + setattr(new_state, key, bool(value)) + else: + setattr(new_state, key, value) + else: + setattr(new_state, key, value) + + # Recalculate urgencies + new_state._calculate_urgencies() + + return new_state + + def get_cost(self, state: WorldState) -> float: + """Get the cost of this action in the given state.""" + return self.cost(state) + + def __repr__(self) -> str: + resource = f"({self.target_resource.value})" if self.target_resource else "" + return f"GOAPAction({self.name}{resource})" + + def __hash__(self) -> int: + return hash((self.name, self.action_type, self.target_resource)) + + def __eq__(self, other) -> bool: + if not isinstance(other, GOAPAction): + return False + return (self.name == other.name and + self.action_type == other.action_type and + self.target_resource == other.target_resource) + + +def create_consume_action( + resource_type: ResourceType, + stat_name: str, + stat_increase: float, + secondary_stat: Optional[str] = None, + secondary_increase: float = 0.0, +) -> GOAPAction: + """Factory for creating consume resource actions.""" + count_name = f"{resource_type.value}_count" if resource_type != ResourceType.BERRIES else "berries_count" + if resource_type == ResourceType.MEAT: + count_name = "meat_count" + elif resource_type == ResourceType.WATER: + count_name = "water_count" + + # Map stat name to pct name + pct_name = f"{stat_name}_pct" + secondary_pct = f"{secondary_stat}_pct" if secondary_stat else None + + def preconditions(state: WorldState) -> bool: + count = getattr(state, count_name, 0) + return count > 0 + + def effects(state: WorldState) -> dict: + result = {} + current = getattr(state, pct_name) + result[pct_name] = min(1.0, current + stat_increase) + + if secondary_pct: + current_sec = getattr(state, secondary_pct) + result[secondary_pct] = min(1.0, current_sec + secondary_increase) + + # Reduce resource count + current_count = getattr(state, count_name) + result[count_name] = max(0, current_count - 1) + + # Update food count if consuming food + if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: + result["food_count"] = max(0, state.food_count - 1) + + return result + + def cost(state: WorldState) -> float: + # Consuming is very cheap - 0 energy cost + return 0.5 + + return GOAPAction( + name=f"Consume {resource_type.value}", + action_type=ActionType.CONSUME, + target_resource=resource_type, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + + +def create_gather_action( + action_type: ActionType, + resource_type: ResourceType, + energy_cost: float, + expected_output: int, + success_chance: float = 1.0, +) -> GOAPAction: + """Factory for creating resource gathering actions.""" + count_name = f"{resource_type.value}_count" + if resource_type == ResourceType.BERRIES: + count_name = "berries_count" + elif resource_type == ResourceType.MEAT: + count_name = "meat_count" + + def preconditions(state: WorldState) -> bool: + # Need enough energy and inventory space + energy_needed = abs(energy_cost) / 50.0 # Convert to percentage + return state.energy_pct >= energy_needed + 0.05 and state.inventory_space > 0 + + def effects(state: WorldState) -> dict: + result = {} + + # Spend energy + energy_spent = abs(energy_cost) / 50.0 + result["energy_pct"] = max(0, state.energy_pct - energy_spent) + + # Gain resources (adjusted for success chance) + effective_output = int(expected_output * success_chance) + current = getattr(state, count_name) + result[count_name] = current + effective_output + + # Update food count if gathering food + if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: + result["food_count"] = state.food_count + effective_output + + # Update inventory space + result["inventory_space"] = max(0, state.inventory_space - effective_output) + + return result + + def cost(state: WorldState) -> float: + # Calculate cost based on efficiency (energy per unit of food) + food_per_action = expected_output * success_chance + if food_per_action > 0: + base_cost = abs(energy_cost) / food_per_action * 0.5 + else: + base_cost = abs(energy_cost) / 5.0 + + # Adjust for success chance (penalize unreliable actions slightly) + if success_chance < 1.0: + base_cost *= 1.0 + (1.0 - success_chance) * 0.3 + + # Mild personality adjustments (shouldn't dominate the cost) + if action_type == ActionType.GATHER: + # Cautious agents slightly prefer gathering + base_cost *= (0.9 + state.risk_tolerance * 0.2) + + return base_cost + + return GOAPAction( + name=f"{action_type.value}", + action_type=action_type, + target_resource=resource_type, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + + +def create_buy_action(resource_type: ResourceType) -> GOAPAction: + """Factory for creating market buy actions.""" + can_buy_name = f"can_buy_{resource_type.value}" + if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: + can_buy_name = "can_buy_food" # Simplified - we check specific later + + count_name = f"{resource_type.value}_count" + if resource_type == ResourceType.BERRIES: + count_name = "berries_count" + elif resource_type == ResourceType.MEAT: + count_name = "meat_count" + + price_name = f"{resource_type.value}_market_price" + if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: + price_name = "food_market_price" + + def preconditions(state: WorldState) -> bool: + # Check specific availability + if resource_type == ResourceType.MEAT: + can_buy = state.can_buy_meat + elif resource_type == ResourceType.BERRIES: + can_buy = state.can_buy_berries + else: + can_buy = getattr(state, f"can_buy_{resource_type.value}", False) + + return can_buy and state.inventory_space > 0 + + def effects(state: WorldState) -> dict: + result = {} + + # Get price + if resource_type == ResourceType.MEAT: + price = state.food_market_price + elif resource_type == ResourceType.BERRIES: + price = state.food_market_price + else: + price = getattr(state, price_name, 10) + + # Spend money + result["money"] = state.money - price + + # Gain resource + current = getattr(state, count_name) + result[count_name] = current + 1 + + # Update food count if buying food + if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: + result["food_count"] = state.food_count + 1 + + # Spend small energy + result["energy_pct"] = max(0, state.energy_pct - 0.02) + + # Update inventory + result["inventory_space"] = max(0, state.inventory_space - 1) + + return result + + def cost(state: WorldState) -> float: + # Trading cost is low (1 energy) + base_cost = 0.5 + + # Market-oriented agents prefer buying + base_cost *= (1.5 - state.market_affinity) + + # Check if it's a good deal + if resource_type == ResourceType.MEAT: + price = state.food_market_price + elif resource_type == ResourceType.BERRIES: + price = state.food_market_price + else: + price = getattr(state, price_name, 100) + + # Higher price = higher cost (scaled for 100-500g price range) + # At fair value (~150g), multiplier is ~1.5x + # At min price (100g), multiplier is ~1.33x + base_cost *= (1.0 + price / 300.0) + + return base_cost + + return GOAPAction( + name=f"Buy {resource_type.value}", + action_type=ActionType.TRADE, + target_resource=resource_type, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + + +def create_rest_action() -> GOAPAction: + """Create the rest action.""" + def preconditions(state: WorldState) -> bool: + return state.energy_pct < 0.9 # Only rest if not full + + def effects(state: WorldState) -> dict: + # Rest restores energy (12 out of 50 = 0.24) + return { + "energy_pct": min(1.0, state.energy_pct + 0.24), + } + + def cost(state: WorldState) -> float: + # Resting is cheap but we prefer productive actions + return 2.0 + + return GOAPAction( + name="Rest", + action_type=ActionType.REST, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + + +def create_build_fire_action() -> GOAPAction: + """Create the build fire action.""" + def preconditions(state: WorldState) -> bool: + return state.wood_count > 0 and state.energy_pct >= 0.1 + + def effects(state: WorldState) -> dict: + return { + "heat_pct": min(1.0, state.heat_pct + 0.20), # Fire gives 20 heat out of 100 + "wood_count": max(0, state.wood_count - 1), + "energy_pct": max(0, state.energy_pct - 0.08), # 4 energy cost + } + + def cost(state: WorldState) -> float: + # Building fire is relatively cheap when we have wood + return 1.5 + + return GOAPAction( + name="Build Fire", + action_type=ActionType.BUILD_FIRE, + target_resource=ResourceType.WOOD, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + + +def create_sleep_action() -> GOAPAction: + """Create the sleep action (for night).""" + def preconditions(state: WorldState) -> bool: + return state.is_night + + def effects(state: WorldState) -> dict: + return { + "energy_pct": 1.0, # Full energy restore + } + + def cost(state: WorldState) -> float: + return 0.0 # Sleep is mandatory at night + + return GOAPAction( + name="Sleep", + action_type=ActionType.SLEEP, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + diff --git a/backend/core/goap/actions.py b/backend/core/goap/actions.py new file mode 100644 index 0000000..f20e7a6 --- /dev/null +++ b/backend/core/goap/actions.py @@ -0,0 +1,362 @@ +"""Predefined GOAP actions for agents. + +Actions are organized by category: +- Consume actions: Use resources from inventory +- Gather actions: Produce resources +- Trade actions: Buy/sell on market +- Utility actions: Rest, sleep, build fire +""" + +from typing import Optional, TYPE_CHECKING + +from backend.domain.action import ActionType, ACTION_CONFIG +from backend.domain.resources import ResourceType + +from .world_state import WorldState +from .action import ( + GOAPAction, + create_consume_action, + create_gather_action, + create_buy_action, + create_rest_action, + create_build_fire_action, + create_sleep_action, +) + +if TYPE_CHECKING: + from backend.domain.agent import Agent + from backend.core.market import OrderBook + + +def _get_action_configs(): + """Get action configurations from config.""" + return ACTION_CONFIG + + +# ============================================================================= +# CONSUME ACTIONS +# ============================================================================= + +def _create_drink_water() -> GOAPAction: + """Drink water to restore thirst.""" + return create_consume_action( + resource_type=ResourceType.WATER, + stat_name="thirst", + stat_increase=0.50, # 50 thirst out of 100 + ) + + +def _create_eat_meat() -> GOAPAction: + """Eat meat to restore hunger (primary food source).""" + return create_consume_action( + resource_type=ResourceType.MEAT, + stat_name="hunger", + stat_increase=0.35, # 35 hunger + secondary_stat="energy", + secondary_increase=0.24, # 12 energy + ) + + +def _create_eat_berries() -> GOAPAction: + """Eat berries to restore hunger and some thirst.""" + return create_consume_action( + resource_type=ResourceType.BERRIES, + stat_name="hunger", + stat_increase=0.10, # 10 hunger + secondary_stat="thirst", + secondary_increase=0.04, # 4 thirst + ) + + +CONSUME_ACTIONS = [ + _create_drink_water(), + _create_eat_meat(), + _create_eat_berries(), +] + + +# ============================================================================= +# GATHER ACTIONS +# ============================================================================= + +def _create_get_water() -> GOAPAction: + """Get water from the river.""" + config = _get_action_configs()[ActionType.GET_WATER] + return create_gather_action( + action_type=ActionType.GET_WATER, + resource_type=ResourceType.WATER, + energy_cost=config.energy_cost, + expected_output=1, + success_chance=1.0, + ) + + +def _create_gather_berries() -> GOAPAction: + """Gather berries (safe, reliable).""" + config = _get_action_configs()[ActionType.GATHER] + expected = (config.min_output + config.max_output) // 2 + return create_gather_action( + action_type=ActionType.GATHER, + resource_type=ResourceType.BERRIES, + energy_cost=config.energy_cost, + expected_output=expected, + success_chance=1.0, + ) + + +def _create_hunt() -> GOAPAction: + """Hunt for meat (risky, high reward). + + Hunt should be attractive because: + - Meat gives much more hunger than berries (35 vs 10) + - Meat also gives energy (12) + - You also get hide for clothes + + Cost is balanced against gathering: + - Hunt: -7 energy, 70% success, 2-5 meat + 0-2 hide + - Gather: -3 energy, 100% success, 2-4 berries + + Effective food per energy: + - Hunt: 3.5 meat avg * 0.7 = 2.45 meat = 2.45 * 35 hunger = 85.75 hunger for 7 energy = 12.25 hunger/energy + - Gather: 3 berries avg * 1.0 = 3 berries = 3 * 10 hunger = 30 hunger for 3 energy = 10 hunger/energy + + So hunting is actually MORE efficient per energy for hunger! The cost should reflect this. + """ + config = _get_action_configs()[ActionType.HUNT] + expected = (config.min_output + config.max_output) // 2 + + # Custom preconditions for hunting + def preconditions(state: WorldState) -> bool: + # Need more energy for hunting (but not excessively so) + energy_needed = abs(config.energy_cost) / 50.0 + 0.05 + return state.energy_pct >= energy_needed and state.inventory_space >= 2 + + def effects(state: WorldState) -> dict: + # Account for success chance + effective_meat = int(expected * config.success_chance) + effective_hide = int(1 * config.success_chance) # Average hide + + energy_spent = abs(config.energy_cost) / 50.0 + + return { + "energy_pct": max(0, state.energy_pct - energy_spent), + "meat_count": state.meat_count + effective_meat, + "food_count": state.food_count + effective_meat, + "hide_count": state.hide_count + effective_hide, + "inventory_space": max(0, state.inventory_space - effective_meat - effective_hide), + } + + def cost(state: WorldState) -> float: + # Hunt should be comparable to gather when considering value: + # - Hunt gives 3.5 meat avg (35 hunger each) = 122.5 hunger value + # - Gather gives 3 berries avg (10 hunger each) = 30 hunger value + # Hunt is 4x more valuable for hunger! So cost can be higher but not 4x. + + # Base cost similar to gather + base_cost = 0.6 + + # Success chance penalty (small) + if config.success_chance < 1.0: + base_cost *= 1.0 + (1.0 - config.success_chance) * 0.2 + + # Risk-tolerant agents prefer hunting + # Range: 0.85 (high risk tolerance) to 1.15 (low risk tolerance) + risk_modifier = 1.0 + (0.5 - state.risk_tolerance) * 0.3 + base_cost *= risk_modifier + + # Big bonus if we have no meat - prioritize getting some + if state.meat_count == 0: + base_cost *= 0.6 + + # Bonus if low on food in general + if state.food_count < 2: + base_cost *= 0.8 + + return base_cost + + return GOAPAction( + name="Hunt", + action_type=ActionType.HUNT, + target_resource=ResourceType.MEAT, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + + +def _create_chop_wood() -> GOAPAction: + """Chop wood for fires.""" + config = _get_action_configs()[ActionType.CHOP_WOOD] + expected = (config.min_output + config.max_output) // 2 + return create_gather_action( + action_type=ActionType.CHOP_WOOD, + resource_type=ResourceType.WOOD, + energy_cost=config.energy_cost, + expected_output=expected, + success_chance=config.success_chance, + ) + + +def _create_weave_clothes() -> GOAPAction: + """Craft clothes from hide.""" + config = _get_action_configs()[ActionType.WEAVE] + + def preconditions(state: WorldState) -> bool: + return ( + state.hide_count >= 1 and + not state.has_clothes and + state.energy_pct >= abs(config.energy_cost) / 50.0 + 0.05 + ) + + def effects(state: WorldState) -> dict: + return { + "has_clothes": True, + "hide_count": state.hide_count - 1, + "energy_pct": max(0, state.energy_pct - abs(config.energy_cost) / 50.0), + } + + def cost(state: WorldState) -> float: + return abs(config.energy_cost) / 3.0 + + return GOAPAction( + name="Weave Clothes", + action_type=ActionType.WEAVE, + target_resource=ResourceType.CLOTHES, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + + +GATHER_ACTIONS = [ + _create_get_water(), + _create_gather_berries(), + _create_hunt(), + _create_chop_wood(), + _create_weave_clothes(), +] + + +# ============================================================================= +# TRADE ACTIONS +# ============================================================================= + +def _create_buy_water() -> GOAPAction: + """Buy water from the market.""" + return create_buy_action(ResourceType.WATER) + + +def _create_buy_meat() -> GOAPAction: + """Buy meat from the market.""" + return create_buy_action(ResourceType.MEAT) + + +def _create_buy_berries() -> GOAPAction: + """Buy berries from the market.""" + return create_buy_action(ResourceType.BERRIES) + + +def _create_buy_wood() -> GOAPAction: + """Buy wood from the market.""" + return create_buy_action(ResourceType.WOOD) + + +def _create_sell_action(resource_type: ResourceType, min_keep: int = 1) -> GOAPAction: + """Factory for creating sell actions.""" + count_name = f"{resource_type.value}_count" + if resource_type == ResourceType.BERRIES: + count_name = "berries_count" + elif resource_type == ResourceType.MEAT: + count_name = "meat_count" + + def preconditions(state: WorldState) -> bool: + current = getattr(state, count_name) + return current > min_keep and state.energy_pct >= 0.05 + + def effects(state: WorldState) -> dict: + # Estimate we'll get a reasonable price (around min_price from config) + # This is approximate - actual execution will get real prices + estimated_price = 100 # Base estimate (min_price from config) + + current = getattr(state, count_name) + sell_qty = min(3, current - min_keep) # Sell up to 3, keep minimum + + result = { + "money": state.money + estimated_price * sell_qty, + count_name: current - sell_qty, + "inventory_space": state.inventory_space + sell_qty, + "energy_pct": max(0, state.energy_pct - 0.02), + } + + # Update food count if selling food + if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: + result["food_count"] = state.food_count - sell_qty + + return result + + def cost(state: WorldState) -> float: + # Selling has low cost + base_cost = 1.0 + + # Hoarders reluctant to sell + base_cost *= (0.5 + state.hoarding_rate) + + return base_cost + + return GOAPAction( + name=f"Sell {resource_type.value}", + action_type=ActionType.TRADE, + target_resource=resource_type, + preconditions=preconditions, + effects=effects, + cost=cost, + ) + + +TRADE_ACTIONS = [ + _create_buy_water(), + _create_buy_meat(), + _create_buy_berries(), + _create_buy_wood(), + _create_sell_action(ResourceType.WATER, min_keep=2), + _create_sell_action(ResourceType.MEAT, min_keep=1), + _create_sell_action(ResourceType.BERRIES, min_keep=2), + _create_sell_action(ResourceType.WOOD, min_keep=1), + _create_sell_action(ResourceType.HIDE, min_keep=0), +] + + +# ============================================================================= +# UTILITY ACTIONS +# ============================================================================= + +UTILITY_ACTIONS = [ + create_rest_action(), + create_build_fire_action(), + create_sleep_action(), +] + + +# ============================================================================= +# ALL ACTIONS +# ============================================================================= + +def get_all_actions() -> list[GOAPAction]: + """Get all available GOAP actions.""" + return CONSUME_ACTIONS + GATHER_ACTIONS + TRADE_ACTIONS + UTILITY_ACTIONS + + +def get_action_by_type(action_type: ActionType) -> list[GOAPAction]: + """Get all GOAP actions of a specific type.""" + all_actions = get_all_actions() + return [a for a in all_actions if a.action_type == action_type] + + +def get_action_by_name(name: str) -> Optional[GOAPAction]: + """Get a specific action by name.""" + all_actions = get_all_actions() + for action in all_actions: + if action.name == name: + return action + return None + diff --git a/backend/core/goap/debug.py b/backend/core/goap/debug.py new file mode 100644 index 0000000..06e7ed0 --- /dev/null +++ b/backend/core/goap/debug.py @@ -0,0 +1,258 @@ +"""GOAP Debug utilities for visualization and analysis. + +Provides detailed information about GOAP decision-making for debugging +and visualization purposes. +""" + +from dataclasses import dataclass, field +from typing import Optional, TYPE_CHECKING + +from .world_state import WorldState, create_world_state +from .goal import Goal +from .action import GOAPAction +from .planner import GOAPPlanner, ReactivePlanner, Plan +from .goals import get_all_goals +from .actions import get_all_actions + +if TYPE_CHECKING: + from backend.domain.agent import Agent + from backend.core.market import OrderBook + + +@dataclass +class GoalDebugInfo: + """Debug information for a single goal.""" + name: str + goal_type: str + priority: float + is_satisfied: bool + is_selected: bool = False + + def to_dict(self) -> dict: + return { + "name": self.name, + "goal_type": self.goal_type, + "priority": round(self.priority, 2), + "is_satisfied": self.is_satisfied, + "is_selected": self.is_selected, + } + + +@dataclass +class ActionDebugInfo: + """Debug information for a single action.""" + name: str + action_type: str + target_resource: Optional[str] + is_valid: bool + cost: float + is_in_plan: bool = False + plan_order: int = -1 + + def to_dict(self) -> dict: + return { + "name": self.name, + "action_type": self.action_type, + "target_resource": self.target_resource, + "is_valid": self.is_valid, + "cost": round(self.cost, 2), + "is_in_plan": self.is_in_plan, + "plan_order": self.plan_order, + } + + +@dataclass +class PlanDebugInfo: + """Debug information for the current plan.""" + goal_name: str + actions: list[str] + total_cost: float + plan_length: int + + def to_dict(self) -> dict: + return { + "goal_name": self.goal_name, + "actions": self.actions, + "total_cost": round(self.total_cost, 2), + "plan_length": self.plan_length, + } + + +@dataclass +class GOAPDebugInfo: + """Complete GOAP debug information for an agent.""" + agent_id: str + agent_name: str + world_state: dict + goals: list[GoalDebugInfo] + actions: list[ActionDebugInfo] + current_plan: Optional[PlanDebugInfo] + selected_action: Optional[str] + decision_reason: str + + def to_dict(self) -> dict: + return { + "agent_id": self.agent_id, + "agent_name": self.agent_name, + "world_state": self.world_state, + "goals": [g.to_dict() for g in self.goals], + "actions": [a.to_dict() for a in self.actions], + "current_plan": self.current_plan.to_dict() if self.current_plan else None, + "selected_action": self.selected_action, + "decision_reason": self.decision_reason, + } + + +def get_goap_debug_info( + agent: "Agent", + market: "OrderBook", + step_in_day: int = 1, + day_steps: int = 10, + is_night: bool = False, +) -> GOAPDebugInfo: + """Get detailed GOAP debug information for an agent. + + This function performs the same planning as the actual AI, + but captures detailed information about the decision process. + """ + # Create world state + state = create_world_state( + agent=agent, + market=market, + step_in_day=step_in_day, + day_steps=day_steps, + is_night=is_night, + ) + + # Get goals and actions + all_goals = get_all_goals() + all_actions = get_all_actions() + + # Evaluate all goals + goal_infos = [] + selected_goal = None + selected_plan = None + + # Sort by priority + goals_with_priority = [] + for goal in all_goals: + priority = goal.priority(state) + satisfied = goal.satisfied(state) + goals_with_priority.append((goal, priority, satisfied)) + + goals_with_priority.sort(key=lambda x: x[1], reverse=True) + + # Try planning for each goal + planner = GOAPPlanner(max_iterations=50) + + for goal, priority, satisfied in goals_with_priority: + if priority > 0 and not satisfied: + plan = planner.plan(state, goal, all_actions) + if plan and not plan.is_empty: + selected_goal = goal + selected_plan = plan + break + + # Build goal debug info + for goal, priority, satisfied in goals_with_priority: + info = GoalDebugInfo( + name=goal.name, + goal_type=goal.goal_type.value, + priority=priority, + is_satisfied=satisfied, + is_selected=(goal == selected_goal), + ) + goal_infos.append(info) + + # Build action debug info + action_infos = [] + plan_action_names = [] + if selected_plan: + plan_action_names = [a.name for a in selected_plan.actions] + + for action in all_actions: + is_valid = action.is_valid(state) + cost = action.get_cost(state) if is_valid else float('inf') + + in_plan = action.name in plan_action_names + order = plan_action_names.index(action.name) if in_plan else -1 + + info = ActionDebugInfo( + name=action.name, + action_type=action.action_type.value, + target_resource=action.target_resource.value if action.target_resource else None, + is_valid=is_valid, + cost=cost if cost != float('inf') else -1, + is_in_plan=in_plan, + plan_order=order, + ) + action_infos.append(info) + + # Sort actions: plan actions first (by order), then valid actions, then invalid + action_infos.sort(key=lambda a: ( + 0 if a.is_in_plan else 1, + a.plan_order if a.is_in_plan else 999, + 0 if a.is_valid else 1, + a.cost if a.cost >= 0 else 9999, + )) + + # Build plan debug info + plan_info = None + if selected_plan: + plan_info = PlanDebugInfo( + goal_name=selected_plan.goal.name, + actions=[a.name for a in selected_plan.actions], + total_cost=selected_plan.total_cost, + plan_length=len(selected_plan.actions), + ) + + # Determine selected action and reason + selected_action = None + reason = "No plan found" + + if is_night: + selected_action = "Sleep" + reason = "Night time: sleeping" + elif selected_plan and selected_plan.first_action: + selected_action = selected_plan.first_action.name + reason = f"{selected_plan.goal.name}: {selected_action}" + else: + # Fallback to reactive planning + reactive_planner = ReactivePlanner() + best_action = reactive_planner.select_best_action(state, all_goals, all_actions) + if best_action: + selected_action = best_action.name + reason = f"Reactive: {best_action.name}" + + # Mark the reactive action in the action list + for action_info in action_infos: + if action_info.name == best_action.name: + action_info.is_in_plan = True + action_info.plan_order = 0 + + return GOAPDebugInfo( + agent_id=agent.id, + agent_name=agent.name, + world_state=state.to_dict(), + goals=goal_infos, + actions=action_infos, + current_plan=plan_info, + selected_action=selected_action, + decision_reason=reason, + ) + + +def get_all_agents_goap_debug( + agents: list["Agent"], + market: "OrderBook", + step_in_day: int = 1, + day_steps: int = 10, + is_night: bool = False, +) -> list[GOAPDebugInfo]: + """Get GOAP debug info for all agents.""" + return [ + get_goap_debug_info(agent, market, step_in_day, day_steps, is_night) + for agent in agents + if agent.is_alive() + ] + diff --git a/backend/core/goap/goal.py b/backend/core/goap/goal.py new file mode 100644 index 0000000..84f96d2 --- /dev/null +++ b/backend/core/goap/goal.py @@ -0,0 +1,185 @@ +"""Goal definitions for GOAP planning. + +Goals represent what an agent wants to achieve. Each goal has: +- A name/type for identification +- A condition that checks if the goal is satisfied +- A priority function that determines how important the goal is +- Optional target state values for the planner +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable, Optional + +from .world_state import WorldState + + +class GoalType(Enum): + """Types of goals agents can pursue.""" + # Survival goals - highest priority when needed + SATISFY_THIRST = "satisfy_thirst" + SATISFY_HUNGER = "satisfy_hunger" + MAINTAIN_HEAT = "maintain_heat" + RESTORE_ENERGY = "restore_energy" + + # Resource goals - medium priority + STOCK_WATER = "stock_water" + STOCK_FOOD = "stock_food" + STOCK_WOOD = "stock_wood" + GET_CLOTHES = "get_clothes" + + # Economic goals - lower priority but persistent + BUILD_WEALTH = "build_wealth" + SELL_EXCESS = "sell_excess" + FIND_DEALS = "find_deals" + TRADER_ARBITRAGE = "trader_arbitrage" + + # Night behavior + SLEEP = "sleep" + + +@dataclass +class Goal: + """A goal that an agent can pursue. + + Goals are the driving force of GOAP. The planner searches for + action sequences that transform the current world state into + one where the goal condition is satisfied. + + Attributes: + goal_type: The type of goal (for identification) + name: Human-readable name + is_satisfied: Function that checks if goal is achieved in a state + get_priority: Function that calculates goal priority (higher = more important) + target_state: Optional dict of state values the goal aims for + max_plan_depth: Maximum actions to plan for this goal + """ + goal_type: GoalType + name: str + is_satisfied: Callable[[WorldState], bool] + get_priority: Callable[[WorldState], float] + target_state: dict = field(default_factory=dict) + max_plan_depth: int = 3 + + def satisfied(self, state: WorldState) -> bool: + """Check if goal is satisfied in the given state.""" + return self.is_satisfied(state) + + def priority(self, state: WorldState) -> float: + """Get the priority of this goal in the given state.""" + return self.get_priority(state) + + def __repr__(self) -> str: + return f"Goal({self.name})" + + +def create_survival_goal( + goal_type: GoalType, + name: str, + stat_name: str, + target_pct: float = 0.6, + base_priority: float = 10.0, +) -> Goal: + """Factory for creating survival-related goals. + + Survival goals have high priority when the relevant stat is low. + Priority scales with urgency. + """ + urgency_name = f"{stat_name}_urgency" + pct_name = f"{stat_name}_pct" + + def is_satisfied(state: WorldState) -> bool: + return getattr(state, pct_name) >= target_pct + + def get_priority(state: WorldState) -> float: + urgency = getattr(state, urgency_name) + pct = getattr(state, pct_name) + + if urgency <= 0: + return 0.0 # No need to pursue this goal + + # Priority increases with urgency + # Critical urgency (>1.0) gives very high priority + priority = base_priority * urgency + + # Extra boost when critical + if pct < state.critical_threshold: + priority *= 2.0 + + return priority + + return Goal( + goal_type=goal_type, + name=name, + is_satisfied=is_satisfied, + get_priority=get_priority, + target_state={pct_name: target_pct}, + max_plan_depth=2, # Survival should be quick + ) + + +def create_resource_stock_goal( + goal_type: GoalType, + name: str, + resource_name: str, + target_count: int, + base_priority: float = 5.0, +) -> Goal: + """Factory for creating resource stockpiling goals. + + Resource goals have moderate priority and aim to maintain reserves. + """ + count_name = f"{resource_name}_count" + + def is_satisfied(state: WorldState) -> bool: + return getattr(state, count_name) >= target_count + + def get_priority(state: WorldState) -> float: + current = getattr(state, count_name) + + if current >= target_count: + return 0.0 # Already have enough + + # Priority based on how far from target + deficit = target_count - current + priority = base_priority * (deficit / target_count) + + # Lower priority if survival is urgent + max_urgency = max(state.thirst_urgency, state.hunger_urgency, state.heat_urgency) + if max_urgency > 0.5: + priority *= 0.5 + + # Hoarders prioritize stockpiling more + priority *= (0.8 + state.hoarding_rate * 0.4) + + # Evening boost - stock up before night + if state.is_evening: + priority *= 1.5 + + return priority + + return Goal( + goal_type=goal_type, + name=name, + is_satisfied=is_satisfied, + get_priority=get_priority, + target_state={count_name: target_count}, + max_plan_depth=3, + ) + + +def create_economic_goal( + goal_type: GoalType, + name: str, + is_satisfied: Callable[[WorldState], bool], + get_priority: Callable[[WorldState], float], +) -> Goal: + """Factory for creating economic/trading goals.""" + return Goal( + goal_type=goal_type, + name=name, + is_satisfied=is_satisfied, + get_priority=get_priority, + max_plan_depth=2, + ) + diff --git a/backend/core/goap/goals.py b/backend/core/goap/goals.py new file mode 100644 index 0000000..da9d1af --- /dev/null +++ b/backend/core/goap/goals.py @@ -0,0 +1,411 @@ +"""Predefined goals for GOAP agents. + +Goals are organized into categories: +- Survival goals: Immediate needs (thirst, hunger, heat, energy) +- Resource goals: Building reserves +- Economic goals: Trading and wealth building +""" + +from .goal import Goal, GoalType, create_survival_goal, create_resource_stock_goal, create_economic_goal +from .world_state import WorldState + + +# ============================================================================= +# SURVIVAL GOALS +# ============================================================================= + +def _create_satisfy_thirst_goal() -> Goal: + """Satisfy immediate thirst.""" + return create_survival_goal( + goal_type=GoalType.SATISFY_THIRST, + name="Satisfy Thirst", + stat_name="thirst", + target_pct=0.5, # Want to get to 50% + base_priority=15.0, # Highest base priority - thirst is most dangerous + ) + + +def _create_satisfy_hunger_goal() -> Goal: + """Satisfy immediate hunger.""" + return create_survival_goal( + goal_type=GoalType.SATISFY_HUNGER, + name="Satisfy Hunger", + stat_name="hunger", + target_pct=0.5, + base_priority=12.0, + ) + + +def _create_maintain_heat_goal() -> Goal: + """Maintain body heat.""" + return create_survival_goal( + goal_type=GoalType.MAINTAIN_HEAT, + name="Maintain Heat", + stat_name="heat", + target_pct=0.5, + base_priority=10.0, + ) + + +def _create_restore_energy_goal() -> Goal: + """Restore energy when low.""" + def is_satisfied(state: WorldState) -> bool: + return state.energy_pct >= 0.4 + + def get_priority(state: WorldState) -> float: + if state.energy_pct >= 0.4: + return 0.0 + + # Priority increases as energy decreases + urgency = (0.4 - state.energy_pct) / 0.4 + + # But not if we have more urgent survival needs + max_vital_urgency = max(state.thirst_urgency, state.hunger_urgency, state.heat_urgency) + if max_vital_urgency > 1.5: + # Critical survival need - don't rest + return 0.0 + + base_priority = 6.0 * urgency + + # Evening boost - want energy for night + if state.is_evening: + base_priority *= 1.5 + + return base_priority + + return Goal( + goal_type=GoalType.RESTORE_ENERGY, + name="Restore Energy", + is_satisfied=is_satisfied, + get_priority=get_priority, + target_state={"energy_pct": 0.6}, + max_plan_depth=1, # Just rest + ) + + +SURVIVAL_GOALS = [ + _create_satisfy_thirst_goal(), + _create_satisfy_hunger_goal(), + _create_maintain_heat_goal(), + _create_restore_energy_goal(), +] + + +# ============================================================================= +# RESOURCE GOALS +# ============================================================================= + +def _create_stock_water_goal() -> Goal: + """Maintain water reserves.""" + def is_satisfied(state: WorldState) -> bool: + target = int(2 * (0.5 + state.hoarding_rate)) + return state.water_count >= target + + def get_priority(state: WorldState) -> float: + target = int(2 * (0.5 + state.hoarding_rate)) + + if state.water_count >= target: + return 0.0 + + deficit = target - state.water_count + base_priority = 4.0 * (deficit / max(1, target)) + + # Lower if urgent survival needs + if max(state.thirst_urgency, state.hunger_urgency) > 1.0: + base_priority *= 0.3 + + # Evening boost + if state.is_evening: + base_priority *= 2.0 + + return base_priority + + return Goal( + goal_type=GoalType.STOCK_WATER, + name="Stock Water", + is_satisfied=is_satisfied, + get_priority=get_priority, + max_plan_depth=2, + ) + + +def _create_stock_food_goal() -> Goal: + """Maintain food reserves (meat + berries).""" + def is_satisfied(state: WorldState) -> bool: + target = int(3 * (0.5 + state.hoarding_rate)) + return state.food_count >= target + + def get_priority(state: WorldState) -> float: + target = int(3 * (0.5 + state.hoarding_rate)) + + if state.food_count >= target: + return 0.0 + + deficit = target - state.food_count + base_priority = 4.0 * (deficit / max(1, target)) + + # Lower if urgent survival needs + if max(state.thirst_urgency, state.hunger_urgency) > 1.0: + base_priority *= 0.3 + + # Evening boost + if state.is_evening: + base_priority *= 2.0 + + # Risk-takers may prefer hunting (more food per action) + base_priority *= (0.8 + state.risk_tolerance * 0.4) + + return base_priority + + return Goal( + goal_type=GoalType.STOCK_FOOD, + name="Stock Food", + is_satisfied=is_satisfied, + get_priority=get_priority, + max_plan_depth=2, + ) + + +def _create_stock_wood_goal() -> Goal: + """Maintain wood reserves for fires.""" + def is_satisfied(state: WorldState) -> bool: + target = int(2 * (0.5 + state.hoarding_rate)) + return state.wood_count >= target + + def get_priority(state: WorldState) -> float: + target = int(2 * (0.5 + state.hoarding_rate)) + + if state.wood_count >= target: + return 0.0 + + deficit = target - state.wood_count + base_priority = 3.0 * (deficit / max(1, target)) + + # Higher priority if heat is becoming an issue + if state.heat_urgency > 0.5: + base_priority *= 1.5 + + # Lower if urgent survival needs + if max(state.thirst_urgency, state.hunger_urgency) > 1.0: + base_priority *= 0.3 + + return base_priority + + return Goal( + goal_type=GoalType.STOCK_WOOD, + name="Stock Wood", + is_satisfied=is_satisfied, + get_priority=get_priority, + max_plan_depth=2, + ) + + +def _create_get_clothes_goal() -> Goal: + """Get clothes for heat protection.""" + def is_satisfied(state: WorldState) -> bool: + return state.has_clothes + + def get_priority(state: WorldState) -> float: + if state.has_clothes: + return 0.0 + + # Only pursue if we have hide + if state.hide_count < 1: + return 0.0 + + base_priority = 2.0 + + # Higher if heat is an issue + if state.heat_urgency > 0.3: + base_priority *= 1.5 + + return base_priority + + return Goal( + goal_type=GoalType.GET_CLOTHES, + name="Get Clothes", + is_satisfied=is_satisfied, + get_priority=get_priority, + max_plan_depth=1, + ) + + +RESOURCE_GOALS = [ + _create_stock_water_goal(), + _create_stock_food_goal(), + _create_stock_wood_goal(), + _create_get_clothes_goal(), +] + + +# ============================================================================= +# ECONOMIC GOALS +# ============================================================================= + +def _create_build_wealth_goal() -> Goal: + """Accumulate money through trading.""" + def is_satisfied(state: WorldState) -> bool: + return state.is_wealthy + + def get_priority(state: WorldState) -> float: + if state.is_wealthy: + return 0.0 + + # Base priority scaled by wealth desire + base_priority = 2.0 * state.wealth_desire + + # Only when survival is stable + max_urgency = max(state.thirst_urgency, state.hunger_urgency, state.heat_urgency) + if max_urgency > 0.5: + return 0.0 + + # Traders prioritize wealth more + if state.is_trader: + base_priority *= 2.0 + + return base_priority + + return create_economic_goal( + goal_type=GoalType.BUILD_WEALTH, + name="Build Wealth", + is_satisfied=is_satisfied, + get_priority=get_priority, + ) + + +def _create_sell_excess_goal() -> Goal: + """Sell excess resources on the market.""" + def is_satisfied(state: WorldState) -> bool: + # Satisfied if inventory is not getting full + return state.inventory_space > 3 + + def get_priority(state: WorldState) -> float: + if state.inventory_space > 5: + return 0.0 # Plenty of space + + # Priority increases as inventory fills + fullness = 1.0 - (state.inventory_space / 12.0) + base_priority = 3.0 * fullness + + # Low hoarders sell more readily + base_priority *= (1.5 - state.hoarding_rate) + + # Only when survival is stable + max_urgency = max(state.thirst_urgency, state.hunger_urgency) + if max_urgency > 0.5: + base_priority *= 0.5 + + return base_priority + + return create_economic_goal( + goal_type=GoalType.SELL_EXCESS, + name="Sell Excess", + is_satisfied=is_satisfied, + get_priority=get_priority, + ) + + +def _create_find_deals_goal() -> Goal: + """Find good deals on the market.""" + def is_satisfied(state: WorldState) -> bool: + # This goal is never fully "satisfied" - it's opportunistic + return False + + def get_priority(state: WorldState) -> float: + # Only pursue if we have money and market access + if state.money < 10: + return 0.0 + + # Check if there are deals available + has_deals = state.can_buy_water or state.can_buy_food or state.can_buy_wood + if not has_deals: + return 0.0 + + # Base priority from market affinity + base_priority = 2.0 * state.market_affinity + + # Only when survival is stable + max_urgency = max(state.thirst_urgency, state.hunger_urgency) + if max_urgency > 0.5: + return 0.0 + + # Need inventory space + if state.inventory_space < 2: + return 0.0 + + return base_priority + + return create_economic_goal( + goal_type=GoalType.FIND_DEALS, + name="Find Deals", + is_satisfied=is_satisfied, + get_priority=get_priority, + ) + + +def _create_trader_arbitrage_goal() -> Goal: + """Trader-specific arbitrage goal (buy low, sell high).""" + def is_satisfied(state: WorldState) -> bool: + return False # Always looking for opportunities + + def get_priority(state: WorldState) -> float: + # Only for traders + if not state.is_trader: + return 0.0 + + # Need capital to trade + if state.money < 20: + return 1.0 # Low priority - need to sell something first + + # Base priority for traders + base_priority = 5.0 + + # Only when survival is stable + max_urgency = max(state.thirst_urgency, state.hunger_urgency, state.heat_urgency) + if max_urgency > 0.3: + base_priority *= 0.5 + + return base_priority + + return create_economic_goal( + goal_type=GoalType.TRADER_ARBITRAGE, + name="Trader Arbitrage", + is_satisfied=is_satisfied, + get_priority=get_priority, + ) + + +def _create_sleep_goal() -> Goal: + """Sleep at night.""" + def is_satisfied(state: WorldState) -> bool: + return not state.is_night # Satisfied when it's not night + + def get_priority(state: WorldState) -> float: + if not state.is_night: + return 0.0 + + # Highest priority at night + return 100.0 + + return Goal( + goal_type=GoalType.SLEEP, + name="Sleep", + is_satisfied=is_satisfied, + get_priority=get_priority, + max_plan_depth=1, + ) + + +ECONOMIC_GOALS = [ + _create_build_wealth_goal(), + _create_sell_excess_goal(), + _create_find_deals_goal(), + _create_trader_arbitrage_goal(), + _create_sleep_goal(), +] + + +def get_all_goals() -> list[Goal]: + """Get all available goals.""" + return SURVIVAL_GOALS + RESOURCE_GOALS + ECONOMIC_GOALS + diff --git a/backend/core/goap/goap_ai.py b/backend/core/goap/goap_ai.py new file mode 100644 index 0000000..281d0f6 --- /dev/null +++ b/backend/core/goap/goap_ai.py @@ -0,0 +1,411 @@ +"""GOAP-based AI decision system for agents. + +This module provides the main interface for GOAP-based decision making. +It replaces the priority-based AgentAI with a goal-oriented planner. +""" + +from dataclasses import dataclass, field +from typing import Optional, TYPE_CHECKING + +from backend.domain.action import ActionType +from backend.domain.resources import ResourceType +from backend.domain.personality import get_trade_price_modifier + +from .world_state import WorldState, create_world_state +from .goal import Goal +from .action import GOAPAction +from .planner import GOAPPlanner, ReactivePlanner, Plan +from .goals import get_all_goals +from .actions import get_all_actions + +if TYPE_CHECKING: + from backend.domain.agent import Agent + from backend.core.market import OrderBook + from backend.core.ai import AIDecision, TradeItem + + +@dataclass +class TradeItem: + """A single item to buy/sell in a trade.""" + order_id: str + resource_type: ResourceType + quantity: int + price_per_unit: int + + +@dataclass +class AIDecision: + """A decision made by the AI for an agent.""" + action: ActionType + target_resource: Optional[ResourceType] = None + order_id: Optional[str] = None + quantity: int = 1 + price: int = 0 + reason: str = "" + trade_items: list[TradeItem] = field(default_factory=list) + adjust_order_id: Optional[str] = None + new_price: Optional[int] = None + + # GOAP-specific fields + goal_name: str = "" + plan_length: int = 0 + + def to_dict(self) -> dict: + return { + "action": self.action.value, + "target_resource": self.target_resource.value if self.target_resource else None, + "order_id": self.order_id, + "quantity": self.quantity, + "price": self.price, + "reason": self.reason, + "trade_items": [ + { + "order_id": t.order_id, + "resource_type": t.resource_type.value, + "quantity": t.quantity, + "price_per_unit": t.price_per_unit, + } + for t in self.trade_items + ], + "adjust_order_id": self.adjust_order_id, + "new_price": self.new_price, + "goal_name": self.goal_name, + "plan_length": self.plan_length, + } + + +class GOAPAgentAI: + """GOAP-based AI decision maker for agents. + + This uses goal-oriented action planning to select actions: + 1. Build world state from agent and market + 2. Evaluate all goals and their priorities + 3. Use planner to find action sequence for best goal + 4. Return the first action as the decision + + Falls back to reactive planning for simple decisions. + """ + + def __init__( + self, + agent: "Agent", + market: "OrderBook", + step_in_day: int = 1, + day_steps: int = 10, + current_turn: int = 0, + is_night: bool = False, + ): + self.agent = agent + self.market = market + self.step_in_day = step_in_day + self.day_steps = day_steps + self.current_turn = current_turn + self.is_night = is_night + + # Build world state + self.state = create_world_state( + agent=agent, + market=market, + step_in_day=step_in_day, + day_steps=day_steps, + is_night=is_night, + ) + + # Initialize planners + self.planner = GOAPPlanner(max_iterations=50) + self.reactive_planner = ReactivePlanner() + + # Get available goals and actions + self.goals = get_all_goals() + self.actions = get_all_actions() + + # Personality shortcuts + self.p = agent.personality + self.skills = agent.skills + + def decide(self) -> AIDecision: + """Make a decision using GOAP planning. + + Decision flow: + 1. Force sleep if night + 2. Try to find a plan for the highest priority goal + 3. If no plan found, use reactive selection + 4. Convert GOAP action to AIDecision with proper parameters + """ + # Night time - mandatory sleep + if self.is_night: + return AIDecision( + action=ActionType.SLEEP, + reason="Night time: sleeping", + goal_name="Sleep", + ) + + # Try GOAP planning + plan = self.planner.plan_for_goals( + initial_state=self.state, + goals=self.goals, + available_actions=self.actions, + ) + + if plan and not plan.is_empty: + # We have a plan - execute first action + goap_action = plan.first_action + return self._convert_to_decision( + goap_action=goap_action, + goal=plan.goal, + plan=plan, + ) + + # Fallback to reactive selection + best_action = self.reactive_planner.select_best_action( + state=self.state, + goals=self.goals, + available_actions=self.actions, + ) + + if best_action: + return self._convert_to_decision( + goap_action=best_action, + goal=None, + plan=None, + ) + + # Ultimate fallback - rest + return AIDecision( + action=ActionType.REST, + reason="No valid action found, resting", + ) + + def _convert_to_decision( + self, + goap_action: GOAPAction, + goal: Optional[Goal], + plan: Optional[Plan], + ) -> AIDecision: + """Convert a GOAP action to an AIDecision with proper parameters. + + This handles the translation from abstract GOAP actions to + concrete decisions with order IDs, prices, etc. + """ + action_type = goap_action.action_type + target_resource = goap_action.target_resource + + # Build reason string + if goal: + reason = f"{goal.name}: {goap_action.name}" + else: + reason = f"Reactive: {goap_action.name}" + + # Handle different action types + if action_type == ActionType.CONSUME: + return AIDecision( + action=action_type, + target_resource=target_resource, + reason=reason, + goal_name=goal.name if goal else "", + plan_length=len(plan.actions) if plan else 0, + ) + + elif action_type == ActionType.TRADE: + return self._create_trade_decision(goap_action, goal, plan, reason) + + elif action_type in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD, + ActionType.GET_WATER, ActionType.WEAVE]: + return AIDecision( + action=action_type, + target_resource=target_resource, + reason=reason, + goal_name=goal.name if goal else "", + plan_length=len(plan.actions) if plan else 0, + ) + + elif action_type == ActionType.BUILD_FIRE: + return AIDecision( + action=action_type, + target_resource=ResourceType.WOOD, + reason=reason, + goal_name=goal.name if goal else "", + plan_length=len(plan.actions) if plan else 0, + ) + + elif action_type in [ActionType.REST, ActionType.SLEEP]: + return AIDecision( + action=action_type, + reason=reason, + goal_name=goal.name if goal else "", + plan_length=len(plan.actions) if plan else 0, + ) + + # Default case + return AIDecision( + action=action_type, + target_resource=target_resource, + reason=reason, + goal_name=goal.name if goal else "", + plan_length=len(plan.actions) if plan else 0, + ) + + def _create_trade_decision( + self, + goap_action: GOAPAction, + goal: Optional[Goal], + plan: Optional[Plan], + reason: str, + ) -> AIDecision: + """Create a trade decision with actual market parameters. + + This translates abstract "Buy X" or "Sell X" actions into + concrete decisions with order IDs, prices, and quantities. + """ + target_resource = goap_action.target_resource + action_name = goap_action.name.lower() + + if "buy" in action_name: + # Find the best order to buy from + order = self.market.get_cheapest_order(target_resource) + + if order and order.seller_id != self.agent.id: + # Calculate quantity to buy + can_afford = self.agent.money // max(1, order.price_per_unit) + space = self.agent.inventory_space() + quantity = min(2, can_afford, space, order.quantity) + + if quantity > 0: + return AIDecision( + action=ActionType.TRADE, + target_resource=target_resource, + order_id=order.id, + quantity=quantity, + price=order.price_per_unit, + reason=f"{reason} @ {order.price_per_unit}c", + goal_name=goal.name if goal else "", + plan_length=len(plan.actions) if plan else 0, + ) + + # Can't buy - fallback to gathering + return self._create_gather_fallback(target_resource, reason, goal, plan) + + elif "sell" in action_name: + # Create a sell order + quantity_available = self.agent.get_resource_count(target_resource) + + # Calculate minimum to keep + min_keep = self._get_min_keep(target_resource) + quantity_to_sell = min(3, quantity_available - min_keep) + + if quantity_to_sell > 0: + price = self._calculate_sell_price(target_resource) + + return AIDecision( + action=ActionType.TRADE, + target_resource=target_resource, + quantity=quantity_to_sell, + price=price, + reason=f"{reason} @ {price}c", + goal_name=goal.name if goal else "", + plan_length=len(plan.actions) if plan else 0, + ) + + # Invalid trade action - rest + return AIDecision( + action=ActionType.REST, + reason="Trade not possible", + ) + + def _create_gather_fallback( + self, + resource_type: ResourceType, + reason: str, + goal: Optional[Goal], + plan: Optional[Plan], + ) -> AIDecision: + """Create a gather action as fallback when buying isn't possible.""" + # Map resource to gather action + action_map = { + ResourceType.WATER: ActionType.GET_WATER, + ResourceType.BERRIES: ActionType.GATHER, + ResourceType.MEAT: ActionType.HUNT, + ResourceType.WOOD: ActionType.CHOP_WOOD, + } + + action = action_map.get(resource_type, ActionType.GATHER) + + return AIDecision( + action=action, + target_resource=resource_type, + reason=f"{reason} (gathering instead)", + goal_name=goal.name if goal else "", + plan_length=len(plan.actions) if plan else 0, + ) + + def _get_min_keep(self, resource_type: ResourceType) -> int: + """Get minimum quantity to keep for survival.""" + # Adjusted by hoarding rate + hoarding_mult = 0.5 + self.p.hoarding_rate + + base_min = { + ResourceType.WATER: 2, + ResourceType.MEAT: 1, + ResourceType.BERRIES: 2, + ResourceType.WOOD: 1, + ResourceType.HIDE: 0, + } + + return int(base_min.get(resource_type, 1) * hoarding_mult) + + def _calculate_sell_price(self, resource_type: ResourceType) -> int: + """Calculate sell price based on fair value and market conditions.""" + # Get energy cost to produce + from backend.core.ai import get_energy_cost + from backend.config import get_config + + config = get_config() + economy = getattr(config, 'economy', None) + energy_to_money_ratio = getattr(economy, 'energy_to_money_ratio', 150) if economy else 150 + min_price = getattr(economy, 'min_price', 100) if economy else 100 + + energy_cost = get_energy_cost(resource_type) + fair_value = max(min_price, int(energy_cost * energy_to_money_ratio)) + + # Apply trading skill + sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False) + + # Get market signal + signal = self.market.get_market_signal(resource_type) + + if signal == "sell": # Scarcity + price = int(fair_value * 1.3 * sell_modifier) + elif signal == "hold": + price = int(fair_value * sell_modifier) + else: # Surplus + cheapest = self.market.get_cheapest_order(resource_type) + if cheapest and cheapest.seller_id != self.agent.id: + price = max(min_price, cheapest.price_per_unit - 1) + else: + price = int(fair_value * 0.8 * sell_modifier) + + return max(min_price, price) + + +def get_goap_decision( + agent: "Agent", + market: "OrderBook", + step_in_day: int = 1, + day_steps: int = 10, + current_turn: int = 0, + is_night: bool = False, +) -> AIDecision: + """Convenience function to get a GOAP-based AI decision for an agent. + + This is the main entry point for the GOAP AI system. + """ + ai = GOAPAgentAI( + agent=agent, + market=market, + step_in_day=step_in_day, + day_steps=day_steps, + current_turn=current_turn, + is_night=is_night, + ) + return ai.decide() + diff --git a/backend/core/goap/planner.py b/backend/core/goap/planner.py new file mode 100644 index 0000000..bab4c36 --- /dev/null +++ b/backend/core/goap/planner.py @@ -0,0 +1,335 @@ +"""GOAP Planner using A* search. + +The planner finds optimal action sequences to achieve goals. +It uses A* search with the goal condition as the target. +""" + +import heapq +from dataclasses import dataclass, field +from typing import Optional + +from .world_state import WorldState +from .goal import Goal +from .action import GOAPAction + + +@dataclass(order=True) +class PlanNode: + """A node in the planning search tree.""" + f_cost: float # Total cost (g + h) + g_cost: float = field(compare=False) # Cost so far + state: WorldState = field(compare=False) + action: Optional[GOAPAction] = field(compare=False, default=None) + parent: Optional["PlanNode"] = field(compare=False, default=None) + depth: int = field(compare=False, default=0) + + +@dataclass +class Plan: + """A plan is a sequence of actions to achieve a goal.""" + goal: Goal + actions: list[GOAPAction] + total_cost: float + expected_final_state: WorldState + + @property + def first_action(self) -> Optional[GOAPAction]: + """Get the first action to execute.""" + return self.actions[0] if self.actions else None + + @property + def is_empty(self) -> bool: + return len(self.actions) == 0 + + def __repr__(self) -> str: + action_names = " -> ".join(a.name for a in self.actions) + return f"Plan({self.goal.name}: {action_names} [cost={self.total_cost:.1f}])" + + +class GOAPPlanner: + """A* planner for GOAP. + + Finds the lowest-cost sequence of actions that transforms + the current world state into one where the goal is satisfied. + """ + + def __init__(self, max_iterations: int = 100): + self.max_iterations = max_iterations + + def plan( + self, + initial_state: WorldState, + goal: Goal, + available_actions: list[GOAPAction], + ) -> Optional[Plan]: + """Find an action sequence to achieve the goal. + + Uses A* search where: + - g(n) = accumulated action costs + - h(n) = heuristic estimate to goal (0 for now - effectively Dijkstra's) + + Returns None if no plan found within iteration limit. + """ + # Check if goal is already satisfied + if goal.satisfied(initial_state): + return Plan( + goal=goal, + actions=[], + total_cost=0.0, + expected_final_state=initial_state, + ) + + # Priority queue for A* + open_set: list[PlanNode] = [] + start_node = PlanNode( + f_cost=0.0, + g_cost=0.0, + state=initial_state, + action=None, + parent=None, + depth=0, + ) + heapq.heappush(open_set, start_node) + + # Track visited states to avoid cycles + # We use a simplified state hash for efficiency + visited: set[tuple] = set() + + iterations = 0 + + while open_set and iterations < self.max_iterations: + iterations += 1 + + # Get node with lowest f_cost + current = heapq.heappop(open_set) + + # Check depth limit + if current.depth >= goal.max_plan_depth: + continue + + # Create state hash for cycle detection + state_hash = self._hash_state(current.state) + if state_hash in visited: + continue + visited.add(state_hash) + + # Try each action + for action in available_actions: + # Check if action is valid in current state + if not action.is_valid(current.state): + continue + + # Apply action to get new state + new_state = action.apply(current.state) + + # Calculate costs + action_cost = action.get_cost(current.state) + g_cost = current.g_cost + action_cost + h_cost = self._heuristic(new_state, goal) + f_cost = g_cost + h_cost + + # Create new node + new_node = PlanNode( + f_cost=f_cost, + g_cost=g_cost, + state=new_state, + action=action, + parent=current, + depth=current.depth + 1, + ) + + # Check if goal is satisfied + if goal.satisfied(new_state): + # Reconstruct and return plan + return self._reconstruct_plan(new_node, goal) + + # Add to open set + heapq.heappush(open_set, new_node) + + # No plan found + return None + + def plan_for_goals( + self, + initial_state: WorldState, + goals: list[Goal], + available_actions: list[GOAPAction], + ) -> Optional[Plan]: + """Find the best plan among multiple goals. + + Selects the highest-priority goal that has a valid plan, + considering both goal priority and plan cost. + """ + # Sort goals by priority (highest first) + sorted_goals = sorted(goals, key=lambda g: g.priority(initial_state), reverse=True) + + best_plan: Optional[Plan] = None + best_score = float('-inf') + + for goal in sorted_goals: + priority = goal.priority(initial_state) + + # Skip low-priority goals if we already have a good plan + if priority <= 0: + continue + + if best_plan and priority < best_score * 0.5: + # This goal is much lower priority, skip + break + + plan = self.plan(initial_state, goal, available_actions) + + if plan: + # Score = priority / (cost + 1) + # Higher priority and lower cost = better + score = priority / (plan.total_cost + 1.0) + + if score > best_score: + best_score = score + best_plan = plan + + return best_plan + + def _hash_state(self, state: WorldState) -> tuple: + """Create a hashable representation of key state values. + + We don't hash everything - just the values that matter for planning. + """ + return ( + round(state.thirst_pct, 1), + round(state.hunger_pct, 1), + round(state.heat_pct, 1), + round(state.energy_pct, 1), + state.water_count, + state.food_count, + state.wood_count, + state.money // 10, # Bucket money + ) + + def _heuristic(self, state: WorldState, goal: Goal) -> float: + """Estimate cost to reach goal from state. + + For now, we use a simple heuristic based on the distance + from current state values to goal target values. + """ + if not goal.target_state: + return 0.0 + + h = 0.0 + for key, target in goal.target_state.items(): + if hasattr(state, key): + current = getattr(state, key) + if isinstance(current, (int, float)) and isinstance(target, (int, float)): + diff = abs(target - current) + h += diff + + return h + + def _reconstruct_plan(self, final_node: PlanNode, goal: Goal) -> Plan: + """Reconstruct the action sequence from the final node.""" + actions = [] + node = final_node + + while node.parent is not None: + if node.action: + actions.append(node.action) + node = node.parent + + actions.reverse() + + return Plan( + goal=goal, + actions=actions, + total_cost=final_node.g_cost, + expected_final_state=final_node.state, + ) + + +class ReactivePlanner: + """A simpler reactive planner for immediate needs. + + Sometimes we don't need full planning - we just need to + pick the best immediate action. This planner evaluates + single actions against goals. + """ + + def select_best_action( + self, + state: WorldState, + goals: list[Goal], + available_actions: list[GOAPAction], + ) -> Optional[GOAPAction]: + """Select the single best action to take right now. + + Evaluates each valid action and scores it based on how well + it progresses toward high-priority goals. + """ + best_action: Optional[GOAPAction] = None + best_score = float('-inf') + + for action in available_actions: + if not action.is_valid(state): + continue + + score = self._score_action(state, action, goals) + + if score > best_score: + best_score = score + best_action = action + + return best_action + + def _score_action( + self, + state: WorldState, + action: GOAPAction, + goals: list[Goal], + ) -> float: + """Score an action based on its contribution to goals.""" + # Apply action to get expected new state + new_state = action.apply(state) + action_cost = action.get_cost(state) + + total_score = 0.0 + + for goal in goals: + priority = goal.priority(state) + if priority <= 0: + continue + + # Check if this action helps with the goal + was_satisfied = goal.satisfied(state) + now_satisfied = goal.satisfied(new_state) + + if now_satisfied and not was_satisfied: + # Action satisfies the goal - big bonus! + total_score += priority * 10.0 + elif not was_satisfied: + # Check if we made progress + # This is a simplified check based on urgencies + old_urgency = self._get_goal_urgency(goal, state) + new_urgency = self._get_goal_urgency(goal, new_state) + + if new_urgency < old_urgency: + improvement = old_urgency - new_urgency + total_score += priority * improvement * 5.0 + + # Subtract cost + total_score -= action_cost + + return total_score + + def _get_goal_urgency(self, goal: Goal, state: WorldState) -> float: + """Get the urgency related to a goal.""" + # Map goal types to state urgencies + from .goal import GoalType + + urgency_map = { + GoalType.SATISFY_THIRST: state.thirst_urgency, + GoalType.SATISFY_HUNGER: state.hunger_urgency, + GoalType.MAINTAIN_HEAT: state.heat_urgency, + GoalType.RESTORE_ENERGY: state.energy_urgency, + } + + return urgency_map.get(goal.goal_type, 0.0) + diff --git a/backend/core/goap/world_state.py b/backend/core/goap/world_state.py new file mode 100644 index 0000000..991f5cb --- /dev/null +++ b/backend/core/goap/world_state.py @@ -0,0 +1,303 @@ +"""World State representation for GOAP planning. + +The WorldState is a symbolic representation of the agent's current situation, +used by the planner to reason about actions and goals. +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from backend.domain.agent import Agent + from backend.core.market import OrderBook + + +@dataclass +class WorldState: + """Symbolic representation of the world from an agent's perspective. + + This captures all relevant state needed for GOAP planning: + - Agent vital stats (as percentages 0-1) + - Resource counts in inventory + - Market availability + - Economic state + - Time of day + + The state uses normalized values (0-1) for stats to make + threshold comparisons easy and consistent. + """ + + # Vital stats as percentages (0.0 to 1.0) + thirst_pct: float = 1.0 + hunger_pct: float = 1.0 + heat_pct: float = 1.0 + energy_pct: float = 1.0 + + # Resource counts in inventory + water_count: int = 0 + food_count: int = 0 # meat + berries + 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 # Has comfortable money reserves + + # Market availability (can we buy these?) + 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 # Cheapest of meat/berries + wood_market_price: int = 0 + + # Time state + is_night: bool = False + is_evening: bool = False # Near end of day + step_in_day: int = 0 + day_steps: int = 10 + + # Agent personality shortcuts (affect goal priorities) + wealth_desire: float = 0.5 + hoarding_rate: float = 0.5 + risk_tolerance: float = 0.5 + market_affinity: float = 0.5 + is_trader: bool = False + + # Critical thresholds (from config) + critical_threshold: float = 0.25 + low_threshold: float = 0.45 + + # Calculated urgencies (how urgent is each need?) + thirst_urgency: float = 0.0 + hunger_urgency: float = 0.0 + heat_urgency: float = 0.0 + energy_urgency: float = 0.0 + + def __post_init__(self): + """Calculate urgencies after initialization.""" + self._calculate_urgencies() + + def _calculate_urgencies(self): + """Calculate urgency values for each vital stat. + + Urgency is 0 when stat is full, and increases as stat decreases. + Urgency > 1.0 when in critical range. + """ + # Urgency increases as stat decreases + # 0.0 = no urgency, 1.0 = needs attention, 2.0+ = critical + + def calc_urgency(pct: float, critical: float, low: float) -> float: + if pct >= low: + return 0.0 + elif pct >= critical: + # Linear increase from 0 to 1 as we go from low to critical + return 1.0 - (pct - critical) / (low - critical) + else: + # Exponential increase below critical + 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) + + # Energy urgency is different - we care about absolute level for work + 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 copy(self) -> "WorldState": + """Create a copy of this world state.""" + 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, + 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, + }, + "market": { + "can_buy_water": self.can_buy_water, + "can_buy_food": self.can_buy_food, + "can_buy_wood": self.can_buy_wood, + }, + "time": { + "is_night": self.is_night, + "is_evening": self.is_evening, + "step": self.step_in_day, + }, + } + + +def create_world_state( + agent: "Agent", + market: "OrderBook", + step_in_day: int = 1, + day_steps: int = 10, + is_night: bool = False, +) -> WorldState: + """Create a WorldState from an agent and market. + + This is the main factory function for creating world states. + It extracts all relevant information from the agent and market. + """ + from backend.domain.resources import ResourceType + from backend.config import get_config + + config = get_config() + agent_config = config.agent_stats + economy_config = getattr(config, 'economy', None) + + stats = agent.stats + + # Calculate stat percentages + thirst_pct = stats.thirst / stats.MAX_THIRST + hunger_pct = stats.hunger / stats.MAX_HUNGER + heat_pct = stats.heat / stats.MAX_HEAT + energy_pct = stats.energy / stats.MAX_ENERGY + + # Get resource counts + water_count = agent.get_resource_count(ResourceType.WATER) + meat_count = agent.get_resource_count(ResourceType.MEAT) + berries_count = agent.get_resource_count(ResourceType.BERRIES) + wood_count = agent.get_resource_count(ResourceType.WOOD) + hide_count = agent.get_resource_count(ResourceType.HIDE) + food_count = meat_count + berries_count + + # Check market availability + def get_market_info(resource_type: ResourceType) -> tuple[bool, int]: + """Get market availability and price for a resource.""" + 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 + + can_buy_water, water_price = get_market_info(ResourceType.WATER) + can_buy_meat, meat_price = get_market_info(ResourceType.MEAT) + can_buy_berries, berries_price = get_market_info(ResourceType.BERRIES) + can_buy_wood, wood_price = get_market_info(ResourceType.WOOD) + + # Can buy food if we can buy either meat or berries + can_buy_food = can_buy_meat or can_buy_berries + food_price = min( + meat_price if can_buy_meat else float('inf'), + berries_price if can_buy_berries else float('inf') + ) + food_price = food_price if food_price != float('inf') else 0 + + # Wealth calculation + min_wealth_target = getattr(economy_config, 'min_wealth_target', 50) if economy_config else 50 + wealth_target = int(min_wealth_target * (0.5 + agent.personality.wealth_desire)) + is_wealthy = agent.money >= wealth_target + + # Trader check + is_trader = agent.personality.trade_preference > 1.3 and agent.personality.market_affinity > 0.5 + + # Evening check (last 2 steps before night) + is_evening = step_in_day >= day_steps - 2 + + return WorldState( + thirst_pct=thirst_pct, + hunger_pct=hunger_pct, + heat_pct=heat_pct, + energy_pct=energy_pct, + water_count=water_count, + food_count=food_count, + meat_count=meat_count, + berries_count=berries_count, + wood_count=wood_count, + hide_count=hide_count, + has_clothes=agent.has_clothes(), + inventory_space=agent.inventory_space(), + inventory_full=agent.inventory_full(), + money=agent.money, + is_wealthy=is_wealthy, + can_buy_water=can_buy_water, + can_buy_food=can_buy_food, + can_buy_meat=can_buy_meat, + can_buy_berries=can_buy_berries, + can_buy_wood=can_buy_wood, + water_market_price=water_price, + food_market_price=int(food_price), + wood_market_price=wood_price, + is_night=is_night, + is_evening=is_evening, + step_in_day=step_in_day, + day_steps=day_steps, + wealth_desire=agent.personality.wealth_desire, + hoarding_rate=agent.personality.hoarding_rate, + risk_tolerance=agent.personality.risk_tolerance, + market_affinity=agent.personality.market_affinity, + is_trader=is_trader, + critical_threshold=agent_config.critical_threshold, + low_threshold=0.45, # Could also be in config + ) + diff --git a/backend/core/market.py b/backend/core/market.py index a95d25a..a447780 100644 --- a/backend/core/market.py +++ b/backend/core/market.py @@ -40,31 +40,32 @@ class Order: seller_id: str = "" resource_type: ResourceType = ResourceType.BERRIES quantity: int = 1 - price_per_unit: int = 1 + price_per_unit: int = 100 # Default to min_price from config created_turn: int = 0 status: OrderStatus = OrderStatus.ACTIVE - + # Price adjustment tracking turns_without_sale: int = 0 original_price: int = 0 last_adjusted_turn: int = 0 # Track when price was last changed - + def __post_init__(self): if self.original_price == 0: self.original_price = self.price_per_unit if self.last_adjusted_turn == 0: self.last_adjusted_turn = self.created_turn - + @property def total_price(self) -> int: """Get total price for all units.""" return self.quantity * self.price_per_unit - + def apply_discount(self, percentage: float = 0.1) -> None: """Apply a discount to the price.""" + min_price = _get_min_price() reduction = max(1, int(self.price_per_unit * percentage)) - self.price_per_unit = max(1, self.price_per_unit - reduction) - + self.price_per_unit = max(min_price, self.price_per_unit - reduction) + def adjust_price(self, new_price: int, current_turn: int) -> bool: """Adjust the order's price. Returns True if successful.""" if new_price < 1: @@ -72,11 +73,11 @@ class Order: self.price_per_unit = new_price self.last_adjusted_turn = current_turn return True - + def can_raise_price(self, current_turn: int, min_turns: int = 2) -> bool: """Check if enough time has passed to raise the price again.""" return current_turn - self.last_adjusted_turn >= min_turns - + def to_dict(self) -> dict: """Convert to dictionary for API serialization.""" return { @@ -104,7 +105,7 @@ class TradeResult: quantity: int = 0 total_paid: int = 0 message: str = "" - + def to_dict(self) -> dict: return { "success": self.success, @@ -124,31 +125,39 @@ def _get_market_config(): return get_config().market +def _get_min_price() -> int: + """Get minimum price floor from economy config.""" + from backend.config import get_config + config = get_config() + economy = getattr(config, 'economy', None) + return getattr(economy, 'min_price', 100) if economy else 100 + + @dataclass class OrderBook: """Central market order book with supply/demand tracking. - + Features: - Track price history per resource type - Calculate supply/demand scores - Suggest prices based on market conditions - Allow sellers to adjust prices dynamically - + Configuration is loaded from config.json. """ orders: list[Order] = field(default_factory=list) trade_history: list[TradeResult] = field(default_factory=list) price_history: dict[ResourceType, PriceHistory] = field(default_factory=dict) - + # Configuration - defaults loaded from config.json in __post_init__ TURNS_BEFORE_DISCOUNT: int = 15 DISCOUNT_RATE: float = 0.12 - + # Supply/demand thresholds LOW_SUPPLY_THRESHOLD: int = 3 # Less than this = scarcity HIGH_SUPPLY_THRESHOLD: int = 10 # More than this = surplus DEMAND_DECAY: float = 0.95 # How fast demand score decays per turn - + def __post_init__(self): """Initialize price history and load config values.""" # Load market config from config.json @@ -158,11 +167,11 @@ class OrderBook: self.DISCOUNT_RATE = cfg.discount_rate except Exception: pass # Use defaults if config not available - + if not self.price_history: for resource_type in ResourceType: self.price_history[resource_type] = PriceHistory() - + def place_order( self, seller_id: str, @@ -181,7 +190,7 @@ class OrderBook: ) self.orders.append(order) return order - + def cancel_order(self, order_id: str, seller_id: str) -> bool: """Cancel an order. Returns True if successful.""" for order in self.orders: @@ -190,11 +199,11 @@ class OrderBook: order.status = OrderStatus.CANCELLED return True return False - + def get_active_orders(self) -> list[Order]: """Get all active orders.""" return [o for o in self.orders if o.status == OrderStatus.ACTIVE] - + def get_orders_by_type(self, resource_type: ResourceType) -> list[Order]: """Get all active orders for a specific resource type, sorted by price.""" orders = [ @@ -202,19 +211,19 @@ class OrderBook: if o.status == OrderStatus.ACTIVE and o.resource_type == resource_type ] return sorted(orders, key=lambda o: o.price_per_unit) - + def get_cheapest_order(self, resource_type: ResourceType) -> Optional[Order]: """Get the cheapest active order for a resource type.""" orders = self.get_orders_by_type(resource_type) return orders[0] if orders else None - + def get_orders_by_seller(self, seller_id: str) -> list[Order]: """Get all active orders from a specific seller.""" return [ o for o in self.orders if o.status == OrderStatus.ACTIVE and o.seller_id == seller_id ] - + def cancel_seller_orders(self, seller_id: str) -> list[Order]: """Cancel all orders from a seller (e.g., when they die). Returns cancelled orders.""" cancelled = [] @@ -223,7 +232,7 @@ class OrderBook: order.status = OrderStatus.CANCELLED cancelled.append(order) return cancelled - + def execute_buy( self, buyer_id: str, @@ -238,13 +247,13 @@ class OrderBook: if o.id == order_id and o.status == OrderStatus.ACTIVE: order = o break - + if order is None: return TradeResult( success=False, message="Order not found or no longer active", ) - + # Check quantity actual_quantity = min(quantity, order.quantity) if actual_quantity <= 0: @@ -252,7 +261,7 @@ class OrderBook: success=False, message="Invalid quantity", ) - + # Check buyer has enough money total_cost = actual_quantity * order.price_per_unit if buyer_money < total_cost: @@ -265,12 +274,12 @@ class OrderBook: message="Insufficient funds", ) total_cost = actual_quantity * order.price_per_unit - + # Execute the trade order.quantity -= actual_quantity if order.quantity <= 0: order.status = OrderStatus.FILLED - + result = TradeResult( success=True, order_id=order.id, @@ -281,12 +290,12 @@ class OrderBook: total_paid=total_cost, message=f"Bought {actual_quantity} {order.resource_type.value} for {total_cost} coins", ) - + # Record sale for price history (we need current_turn but don't have it here) # The turn will be passed via the _record_sale call from engine self.trade_history.append(result) return result - + def execute_multi_buy( self, buyer_id: str, @@ -296,30 +305,30 @@ class OrderBook: """Execute multiple buy orders in one action. Returns list of trade results.""" results = [] remaining_money = buyer_money - + for order_id, quantity in purchases: result = self.execute_buy(buyer_id, order_id, quantity, remaining_money) results.append(result) if result.success: remaining_money -= result.total_paid - + return results - + def update_prices(self, current_turn: int) -> None: """Update order prices and supply/demand scores.""" # Update supply/demand scores self._update_supply_demand_scores(current_turn) - + # Apply automatic discounts to stale orders (keeping original behavior) for order in self.orders: if order.status != OrderStatus.ACTIVE: continue - + turns_waiting = current_turn - order.created_turn if turns_waiting > 0 and turns_waiting % self.TURNS_BEFORE_DISCOUNT == 0: order.turns_without_sale = turns_waiting order.apply_discount(self.DISCOUNT_RATE) - + def _update_supply_demand_scores(self, current_turn: int) -> None: """Calculate current supply and demand scores for each resource.""" for resource_type in ResourceType: @@ -327,7 +336,7 @@ class OrderBook: if not history: history = PriceHistory() self.price_history[resource_type] = history - + # Calculate supply score based on available quantity total_supply = self.get_total_supply(resource_type) if total_supply <= self.LOW_SUPPLY_THRESHOLD: @@ -336,36 +345,36 @@ class OrderBook: history.supply_score = min(1.0, 0.5 + (total_supply / self.HIGH_SUPPLY_THRESHOLD) * 0.5) else: history.supply_score = 0.5 - + # Decay demand score over time history.demand_score *= self.DEMAND_DECAY - + def get_total_supply(self, resource_type: ResourceType) -> int: """Get total quantity available for a resource type.""" return sum(o.quantity for o in self.get_orders_by_type(resource_type)) - + def get_supply_demand_ratio(self, resource_type: ResourceType) -> float: """Get supply/demand ratio. <1 means scarcity, >1 means surplus.""" history = self.price_history.get(resource_type, PriceHistory()) demand = max(0.1, history.demand_score) supply = max(0.1, history.supply_score) return supply / demand - + def get_suggested_price(self, resource_type: ResourceType, base_price: int) -> int: """Suggest a price based on supply/demand conditions. - + Returns an adjusted price that accounts for market conditions: - Scarcity (low supply, high demand) -> higher price - Surplus (high supply, low demand) -> lower price """ ratio = self.get_supply_demand_ratio(resource_type) history = self.price_history.get(resource_type, PriceHistory()) - + # Use average sale price as reference if available reference_price = base_price if history.avg_sale_price > 0: reference_price = int((base_price + history.avg_sale_price) / 2) - + # Adjust based on supply/demand if ratio < 0.7: # Scarcity - raise price price_multiplier = 1.0 + (0.7 - ratio) * 0.5 # Up to 35% increase @@ -374,10 +383,10 @@ class OrderBook: price_multiplier = max(0.5, price_multiplier) # Floor at 50% else: price_multiplier = 1.0 - + suggested = int(reference_price * price_multiplier) - return max(1, suggested) - + return max(_get_min_price(), suggested) + def adjust_order_price(self, order_id: str, seller_id: str, new_price: int, current_turn: int) -> bool: """Adjust the price of an existing order. Returns True if successful.""" for order in self.orders: @@ -385,37 +394,37 @@ class OrderBook: if order.status == OrderStatus.ACTIVE: return order.adjust_price(new_price, current_turn) return False - + def _record_sale(self, resource_type: ResourceType, price: int, quantity: int, current_turn: int) -> None: """Record a sale for price history tracking.""" history = self.price_history.get(resource_type) if not history: history = PriceHistory() self.price_history[resource_type] = history - + history.last_sale_price = price history.last_sale_turn = current_turn - + # Update average sale price old_total = history.avg_sale_price * history.total_sold history.total_sold += quantity history.avg_sale_price = (old_total + price * quantity) / history.total_sold - + # Increase demand score when sales happen history.demand_score = min(1.0, history.demand_score + 0.1 * quantity) - + def cleanup_old_orders(self, max_age: int = 50) -> list[Order]: """Remove very old orders. Returns removed orders.""" # For now, we don't auto-expire orders, but this could be enabled return [] - + def get_market_prices(self) -> dict[str, dict]: """Get current market price summary for each resource.""" prices = {} for resource_type in ResourceType: orders = self.get_orders_by_type(resource_type) history = self.price_history.get(resource_type, PriceHistory()) - + if orders: prices[resource_type.value] = { "lowest_price": orders[0].price_per_unit, @@ -437,7 +446,7 @@ class OrderBook: "demand_score": round(history.demand_score, 2), } return prices - + def get_market_signal(self, resource_type: ResourceType) -> str: """Get a simple market signal for a resource: 'sell', 'hold', or 'buy'.""" ratio = self.get_supply_demand_ratio(resource_type) @@ -446,7 +455,7 @@ class OrderBook: elif ratio > 1.3: return "buy" # Good time to buy - surplus return "hold" - + def get_state_snapshot(self) -> dict: """Get market state for API.""" return { diff --git a/config.json b/config.json index 7cbff9c..e2d124b 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,10 @@ { + "ai": { + "use_goap": true, + "goap_max_iterations": 50, + "goap_max_plan_depth": 3, + "reactive_fallback": true + }, "agent_stats": { "max_energy": 50, "max_hunger": 100, @@ -19,27 +25,27 @@ "meat_decay": 10, "berries_decay": 6, "clothes_decay": 20, - "meat_hunger": 35, - "meat_energy": 12, - "berries_hunger": 10, - "berries_thirst": 4, + "meat_hunger": 45, + "meat_energy": 15, + "berries_hunger": 8, + "berries_thirst": 2, "water_thirst": 50, "fire_heat": 20 }, "actions": { "sleep_energy": 55, "rest_energy": 12, - "hunt_energy": -7, - "gather_energy": -3, + "hunt_energy": -5, + "gather_energy": -4, "chop_wood_energy": -6, "get_water_energy": -2, "weave_energy": -6, "build_fire_energy": -4, "trade_energy": -1, - "hunt_success": 0.70, - "chop_wood_success": 0.90, + "hunt_success": 0.85, + "chop_wood_success": 0.9, "hunt_meat_min": 2, - "hunt_meat_max": 5, + "hunt_meat_max": 4, "hunt_hide_min": 0, "hunt_hide_max": 2, "gather_min": 2, @@ -54,7 +60,7 @@ "day_steps": 10, "night_steps": 1, "inventory_slots": 12, - "starting_money": 80 + "starting_money": 8000 }, "market": { "turns_before_discount": 15, @@ -62,10 +68,11 @@ "base_price_multiplier": 1.3 }, "economy": { - "energy_to_money_ratio": 1.5, + "energy_to_money_ratio": 150, + "min_price": 100, "wealth_desire": 0.35, "buy_efficiency_threshold": 0.75, - "min_wealth_target": 50, + "min_wealth_target": 5000, "max_price_markup": 2.5, "min_price_discount": 0.4 }, diff --git a/config_goap_optimized.json b/config_goap_optimized.json new file mode 100644 index 0000000..1a00e63 --- /dev/null +++ b/config_goap_optimized.json @@ -0,0 +1,79 @@ +{ + "ai": { + "use_goap": true, + "goap_max_iterations": 50, + "goap_max_plan_depth": 3, + "reactive_fallback": true + }, + "agent_stats": { + "max_energy": 50, + "max_hunger": 100, + "max_thirst": 100, + "max_heat": 100, + "start_energy": 50, + "start_hunger": 70, + "start_thirst": 75, + "start_heat": 100, + "energy_decay": 1, + "hunger_decay": 2, + "thirst_decay": 3, + "heat_decay": 3, + "critical_threshold": 0.25, + "low_energy_threshold": 12 + }, + "resources": { + "meat_decay": 10, + "berries_decay": 6, + "clothes_decay": 20, + "meat_hunger": 45, + "meat_energy": 15, + "berries_hunger": 8, + "berries_thirst": 2, + "water_thirst": 50, + "fire_heat": 20 + }, + "actions": { + "sleep_energy": 55, + "rest_energy": 12, + "hunt_energy": -5, + "gather_energy": -4, + "chop_wood_energy": -6, + "get_water_energy": -2, + "weave_energy": -6, + "build_fire_energy": -4, + "trade_energy": -1, + "hunt_success": 0.85, + "chop_wood_success": 0.9, + "hunt_meat_min": 2, + "hunt_meat_max": 4, + "hunt_hide_min": 0, + "hunt_hide_max": 2, + "gather_min": 2, + "gather_max": 4, + "chop_wood_min": 1, + "chop_wood_max": 3 + }, + "world": { + "width": 25, + "height": 25, + "initial_agents": 25, + "day_steps": 10, + "night_steps": 1, + "inventory_slots": 12, + "starting_money": 80 + }, + "market": { + "turns_before_discount": 15, + "discount_rate": 0.12, + "base_price_multiplier": 1.3 + }, + "economy": { + "energy_to_money_ratio": 1.5, + "wealth_desire": 0.35, + "buy_efficiency_threshold": 0.75, + "min_wealth_target": 50, + "max_price_markup": 2.5, + "min_price_discount": 0.4 + }, + "auto_step_interval": 0.15 +} \ No newline at end of file diff --git a/tools/optimize_goap.py b/tools/optimize_goap.py new file mode 100644 index 0000000..c9ba27d --- /dev/null +++ b/tools/optimize_goap.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +""" +GOAP Economy Optimizer for Village Simulation + +This script optimizes the simulation parameters specifically for the GOAP AI system. +The goal is to achieve: +- Balanced action diversity (hunting, gathering, trading) +- Active economy with trading +- Good survival rates +- Meat production through hunting + +Key insight: GOAP uses action COSTS to choose actions. Lower cost = preferred. +We need to tune: +1. Action energy costs (config.json) +2. GOAP action cost functions (goap/actions.py) +3. Goal priorities (goap/goals.py) + +Usage: + python tools/optimize_goap.py [--iterations 15] [--steps 300] + python tools/optimize_goap.py --analyze # Analyze current GOAP behavior +""" + +import argparse +import json +import random +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +# Add parent directory for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from backend.config import get_config, reload_config +from backend.core.engine import GameEngine +from backend.domain.action import reset_action_config_cache +from backend.domain.resources import reset_resource_cache + + +def analyze_goap_behavior(num_steps: int = 100, num_agents: int = 10): + """Analyze current GOAP behavior in detail.""" + print("\n" + "=" * 70) + print("🔍 GOAP BEHAVIOR ANALYSIS") + print("=" * 70) + + # Reset engine + GameEngine._instance = None + engine = GameEngine() + engine.initialize(num_agents=num_agents) + + # Track statistics + action_counts = defaultdict(int) + goal_counts = defaultdict(int) + reactive_count = 0 + planned_count = 0 + + # Resource tracking + resources_produced = defaultdict(int) + resources_consumed = defaultdict(int) + + # Run simulation + for step in range(num_steps): + if not engine.is_running: + print(f" Simulation ended at step {step}") + break + + log = engine.next_step() + + for action_data in log.agent_actions: + decision = action_data.get("decision", {}) + result = action_data.get("result", {}) + + action_type = decision.get("action", "unknown") + action_counts[action_type] += 1 + + # Track goal/reactive + goal_name = decision.get("goal_name", "") + reason = decision.get("reason", "") + + if goal_name: + goal_counts[goal_name] += 1 + planned_count += 1 + elif "Reactive" in reason: + goal_counts["(reactive)"] += 1 + reactive_count += 1 + + # Track resources + if result and result.get("success"): + for res in result.get("resources_gained", []): + resources_produced[res.get("type", "unknown")] += res.get("quantity", 0) + for res in result.get("resources_consumed", []): + resources_consumed[res.get("type", "unknown")] += res.get("quantity", 0) + + # Print results + total_actions = sum(action_counts.values()) + + print(f"\n📊 Action Distribution ({num_steps} turns, {num_agents} agents)") + print("-" * 50) + for action, count in sorted(action_counts.items(), key=lambda x: -x[1]): + pct = count * 100 / total_actions if total_actions > 0 else 0 + bar = "█" * int(pct / 2) + print(f" {action:12} {count:4} ({pct:5.1f}%) {bar}") + + print(f"\n🎯 Goal Distribution") + print("-" * 50) + total_goals = sum(goal_counts.values()) + for goal, count in sorted(goal_counts.items(), key=lambda x: -x[1])[:15]: + pct = count * 100 / total_goals if total_goals > 0 else 0 + print(f" {goal:20} {count:4} ({pct:5.1f}%)") + + print(f"\n Planned actions: {planned_count} ({planned_count*100/total_actions:.1f}%)") + print(f" Reactive actions: {reactive_count} ({reactive_count*100/total_actions:.1f}%)") + + print(f"\n📦 Resources Produced") + print("-" * 50) + for res, qty in sorted(resources_produced.items(), key=lambda x: -x[1]): + print(f" {res:12} {qty:4}") + + print(f"\n🔥 Resources Consumed") + print("-" * 50) + for res, qty in sorted(resources_consumed.items(), key=lambda x: -x[1]): + print(f" {res:12} {qty:4}") + + # Diagnose issues + print(f"\n⚠️ ISSUES DETECTED:") + print("-" * 50) + + hunt_pct = action_counts.get("hunt", 0) * 100 / total_actions if total_actions > 0 else 0 + gather_pct = action_counts.get("gather", 0) * 100 / total_actions if total_actions > 0 else 0 + + if hunt_pct < 5: + print(" ❌ Almost no hunting! Hunt action cost too high or meat not valued enough.") + print(" → Reduce hunt energy cost or increase meat benefits") + + if resources_produced.get("meat", 0) == 0: + print(" ❌ No meat produced! Agents never hunt successfully.") + + trade_pct = action_counts.get("trade", 0) * 100 / total_actions if total_actions > 0 else 0 + if trade_pct < 5: + print(" ❌ Low trading activity. Market goals not prioritized.") + + if reactive_count > planned_count: + print(" ⚠️ More reactive than planned actions. Goals may be too easily satisfied.") + + return { + "action_counts": dict(action_counts), + "goal_counts": dict(goal_counts), + "resources_produced": dict(resources_produced), + "resources_consumed": dict(resources_consumed), + } + + +def test_config(config_overrides: dict, num_steps: int = 200, num_agents: int = 10, verbose: bool = True): + """Test a configuration and return metrics.""" + # Save original config + config_path = Path("config.json") + with open(config_path) as f: + original_config = json.load(f) + + # Apply overrides + test_config = json.loads(json.dumps(original_config)) + for section, values in config_overrides.items(): + if section in test_config: + test_config[section].update(values) + else: + test_config[section] = values + + # Save temp config + temp_path = Path("config_temp.json") + with open(temp_path, 'w') as f: + json.dump(test_config, f, indent=2) + + # Reload config + reload_config(str(temp_path)) + reset_action_config_cache() + reset_resource_cache() + + # Run simulation + GameEngine._instance = None + engine = GameEngine() + engine.initialize(num_agents=num_agents) + + action_counts = defaultdict(int) + resources_produced = defaultdict(int) + deaths = 0 + trades_completed = 0 + + for step in range(num_steps): + if not engine.is_running: + break + + log = engine.next_step() + deaths += len(log.deaths) + + for action_data in log.agent_actions: + decision = action_data.get("decision", {}) + result = action_data.get("result", {}) + + action_type = decision.get("action", "unknown") + action_counts[action_type] += 1 + + if result and result.get("success"): + for res in result.get("resources_gained", []): + resources_produced[res.get("type", "unknown")] += res.get("quantity", 0) + + if action_type == "trade" and "Bought" in result.get("message", ""): + trades_completed += 1 + + final_pop = len(engine.world.get_living_agents()) + + # Cleanup + engine.logger.close() + temp_path.unlink(missing_ok=True) + + # Restore original config + reload_config(str(config_path)) + reset_action_config_cache() + reset_resource_cache() + + # Calculate score + total_actions = sum(action_counts.values()) + hunt_ratio = action_counts.get("hunt", 0) / total_actions if total_actions > 0 else 0 + gather_ratio = action_counts.get("gather", 0) / total_actions if total_actions > 0 else 0 + trade_ratio = action_counts.get("trade", 0) / total_actions if total_actions > 0 else 0 + + survival_rate = final_pop / num_agents + + # Score components + # 1. Hunt ratio: want 10-25% + hunt_score = min(25, hunt_ratio * 100) if hunt_ratio > 0.05 else 0 + + # 2. Trade activity: want 5-15% + trade_score = min(20, trade_ratio * 100 * 2) + + # 3. Resource diversity + has_meat = resources_produced.get("meat", 0) > 0 + has_berries = resources_produced.get("berries", 0) > 0 + has_wood = resources_produced.get("wood", 0) > 0 + has_water = resources_produced.get("water", 0) > 0 + diversity_score = (int(has_meat) + int(has_berries) + int(has_wood) + int(has_water)) * 5 + + # 4. Survival + survival_score = survival_rate * 30 + + # 5. Meat production bonus + meat_score = min(15, resources_produced.get("meat", 0) / 5) + + total_score = hunt_score + trade_score + diversity_score + survival_score + meat_score + + if verbose: + print(f"\n Score: {total_score:.1f}/100") + print(f" ├─ Hunt: {hunt_ratio*100:.1f}% ({hunt_score:.1f} pts)") + print(f" ├─ Trade: {trade_ratio*100:.1f}% ({trade_score:.1f} pts)") + print(f" ├─ Diversity: {diversity_score:.1f} pts") + print(f" ├─ Survival: {survival_rate*100:.0f}% ({survival_score:.1f} pts)") + print(f" └─ Meat produced: {resources_produced.get('meat', 0)} ({meat_score:.1f} pts)") + print(f" Actions: hunt={action_counts.get('hunt',0)}, gather={action_counts.get('gather',0)}, trade={action_counts.get('trade',0)}") + + return { + "score": total_score, + "action_counts": dict(action_counts), + "resources": dict(resources_produced), + "survival_rate": survival_rate, + "deaths": deaths, + } + + +def optimize_for_goap(iterations: int = 15, steps: int = 300): + """Run optimization focused on GOAP-specific parameters.""" + print("\n" + "=" * 70) + print("🧬 GOAP ECONOMY OPTIMIZER") + print("=" * 70) + print(f" Iterations: {iterations}") + print(f" Steps per test: {steps}") + print("=" * 70) + + # Key parameters to optimize for GOAP + # Focus on making hunting more attractive + + configs_to_test = [ + # Baseline + { + "name": "Baseline (current)", + "config": {} + }, + # Cheaper hunting + { + "name": "Cheaper Hunt (-5 energy)", + "config": { + "actions": { + "hunt_energy": -5, + "hunt_success": 0.8, + } + } + }, + # More valuable meat + { + "name": "Valuable Meat (+45 hunger)", + "config": { + "resources": { + "meat_hunger": 45, + "meat_energy": 15, + }, + "actions": { + "hunt_energy": -6, + "hunt_success": 0.8, + } + } + }, + # Make berries less attractive + { + "name": "Nerfed Berries", + "config": { + "resources": { + "meat_hunger": 45, + "meat_energy": 15, + "berries_hunger": 8, + "berries_thirst": 2, + }, + "actions": { + "hunt_energy": -5, + "gather_energy": -4, + "hunt_success": 0.85, + "hunt_meat_min": 2, + "hunt_meat_max": 4, + } + } + }, + # Higher hunt output + { + "name": "High Hunt Output", + "config": { + "resources": { + "meat_hunger": 40, + "meat_energy": 12, + }, + "actions": { + "hunt_energy": -6, + "hunt_success": 0.85, + "hunt_meat_min": 3, + "hunt_meat_max": 6, + "hunt_hide_min": 1, + "hunt_hide_max": 2, + } + } + }, + # Balanced economy + { + "name": "Balanced Economy", + "config": { + "resources": { + "meat_hunger": 40, + "meat_energy": 15, + "berries_hunger": 8, + }, + "actions": { + "hunt_energy": -5, + "gather_energy": -4, + "hunt_success": 0.8, + "hunt_meat_min": 2, + "hunt_meat_max": 5, + }, + "economy": { + "buy_efficiency_threshold": 0.9, + "min_wealth_target": 40, + } + } + }, + # Pro-hunting config + { + "name": "Pro-Hunting", + "config": { + "agent_stats": { + "hunger_decay": 3, # Higher hunger decay = need more food + }, + "resources": { + "meat_hunger": 50, # Meat is very filling + "meat_energy": 15, + "berries_hunger": 6, # Berries less filling + }, + "actions": { + "hunt_energy": -4, # Very cheap to hunt + "gather_energy": -4, + "hunt_success": 0.85, + "hunt_meat_min": 3, + "hunt_meat_max": 5, + } + } + }, + # Full rebalance + { + "name": "Full Rebalance", + "config": { + "agent_stats": { + "start_hunger": 70, + "hunger_decay": 3, + "thirst_decay": 3, + }, + "resources": { + "meat_hunger": 50, + "meat_energy": 15, + "berries_hunger": 8, + "berries_thirst": 3, + "water_thirst": 45, + }, + "actions": { + "hunt_energy": -5, + "gather_energy": -4, + "chop_wood_energy": -5, + "get_water_energy": -3, + "hunt_success": 0.8, + "hunt_meat_min": 2, + "hunt_meat_max": 5, + "hunt_hide_min": 0, + "hunt_hide_max": 1, + "gather_min": 2, + "gather_max": 3, + } + } + }, + ] + + best_config = None + best_score = 0 + best_name = "" + + for cfg in configs_to_test: + print(f"\n🧪 Testing: {cfg['name']}") + print("-" * 50) + + result = test_config(cfg["config"], steps, verbose=True) + + if result["score"] > best_score: + best_score = result["score"] + best_config = cfg["config"] + best_name = cfg["name"] + print(f" ⭐ New best!") + + print("\n" + "=" * 70) + print("🏆 OPTIMIZATION COMPLETE") + print("=" * 70) + print(f"\n Best Config: {best_name}") + print(f" Best Score: {best_score:.1f}/100") + + if best_config: + print("\n 📝 Configuration to apply:") + print("-" * 50) + print(json.dumps(best_config, indent=2)) + + # Ask to apply + print("\n Would you like to apply this configuration? (y/n)") + + # Save as optimized config + output_path = Path("config_goap_optimized.json") + with open("config.json") as f: + full_config = json.load(f) + + for section, values in best_config.items(): + if section in full_config: + full_config[section].update(values) + else: + full_config[section] = values + + with open(output_path, 'w') as f: + json.dump(full_config, f, indent=2) + + print(f"\n ✅ Saved to: {output_path}") + print(" To apply: cp config_goap_optimized.json config.json") + + return best_config + + +def main(): + parser = argparse.ArgumentParser(description="Optimize GOAP economy parameters") + parser.add_argument("--analyze", "-a", action="store_true", help="Analyze current behavior") + parser.add_argument("--iterations", "-i", type=int, default=15, help="Optimization iterations") + parser.add_argument("--steps", "-s", type=int, default=200, help="Steps per simulation") + parser.add_argument("--apply", action="store_true", help="Auto-apply best config") + + args = parser.parse_args() + + if args.analyze: + analyze_goap_behavior(args.steps) + else: + best = optimize_for_goap(args.iterations, args.steps) + + if args.apply and best: + # Apply the config + import shutil + shutil.copy("config_goap_optimized.json", "config.json") + print("\n ✅ Configuration applied!") + + +if __name__ == "__main__": + main() + diff --git a/web_frontend/goap_debug.html b/web_frontend/goap_debug.html new file mode 100644 index 0000000..6a6ab9f --- /dev/null +++ b/web_frontend/goap_debug.html @@ -0,0 +1,820 @@ + + +
+ + +Real-time visualization of agent decision-making
+Loading agents...
+Select an agent to view their GOAP plan
+Select an agent
+