villsim/backend/core/goap/action.py
Снесарев Максим 308f738c37 [new] add goap agents
2026-01-19 20:45:35 +03:00

382 lines
13 KiB
Python

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