"""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 # STRONG profession specialization effect for gathering if action_type == ActionType.GATHER: # Compare gather_preference to other preferences # Specialists get big discounts, generalists pay penalty other_prefs = (state.hunt_preference + state.trade_preference) / 2 relative_strength = state.gather_preference / max(0.1, other_prefs) # relative_strength > 1.0 means gathering is your specialty # relative_strength < 1.0 means you're NOT a gatherer if relative_strength >= 1.0: # Specialist discount: up to 50% off preference_modifier = 1.0 / relative_strength else: # Non-specialist penalty: up to 3x cost preference_modifier = 1.0 + (1.0 - relative_strength) * 2.0 base_cost *= preference_modifier # Skill reduces cost further (experienced = efficient) # skill 0: no bonus, skill 1.0: 40% discount skill_modifier = 1.0 - state.gathering_skill * 0.4 base_cost *= skill_modifier 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 # MILD profession effect for trading (everyone should be able to trade) # Traders get a bonus, but non-traders shouldn't be heavily penalized # (trading benefits the whole economy) other_prefs = (state.hunt_preference + state.gather_preference) / 2 relative_strength = state.trade_preference / max(0.1, other_prefs) if relative_strength >= 1.0: # Specialist discount: up to 40% off for dedicated traders preference_modifier = max(0.6, 1.0 / relative_strength) else: # Mild non-specialist penalty: up to 50% cost increase preference_modifier = 1.0 + (1.0 - relative_strength) * 0.5 base_cost *= preference_modifier # Skill reduces cost (experienced traders are efficient) # skill 0: no bonus, skill 1.0: 40% discount skill_modifier = 1.0 - state.trading_skill * 0.4 base_cost *= skill_modifier # Market affinity still has mild effect base_cost *= (1.2 - state.market_affinity * 0.4) # 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, )