420 lines
14 KiB
Python
420 lines
14 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
|
|
|
|
# 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.2
|
|
|
|
# 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,
|
|
)
|
|
|