villsim/backend/core/goap/actions.py
2026-01-19 21:03:30 +03:00

400 lines
13 KiB
Python

"""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
# STRONG profession specialization effect for hunting
# Compare hunt_preference to other preferences
other_prefs = (state.gather_preference + state.trade_preference) / 2
relative_strength = state.hunt_preference / max(0.1, other_prefs)
# relative_strength > 1.0 means hunting is your specialty
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 hunters are efficient)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.hunting_skill * 0.4
base_cost *= skill_modifier
# Risk tolerance still has mild effect
risk_modifier = 1.0 + (0.5 - state.risk_tolerance) * 0.15
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 - everyone should be able to sell excess
base_cost = 1.0
# MILD profession effect for selling (everyone should be able to trade)
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 know the market)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.trading_skill * 0.4
base_cost *= skill_modifier
# Hoarders reluctant to sell (mild effect)
base_cost *= (0.8 + state.hoarding_rate * 0.4)
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