Compare commits

..

2 Commits
master ... goap

Author SHA1 Message Date
Снесарев Максим
25bd13e001 [upd] rebalance professions + add game controls to the stats page 2026-01-19 21:03:30 +03:00
Снесарев Максим
308f738c37 [new] add goap agents 2026-01-19 20:45:35 +03:00
23 changed files with 5649 additions and 475 deletions

View File

@ -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),
}

View File

@ -109,7 +109,10 @@ class EconomyConfig:
"""
# 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
@ -121,13 +124,33 @@ class EconomyConfig:
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,6 +160,7 @@ 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
@ -144,6 +168,7 @@ class SimulationConfig:
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),
@ -157,6 +182,7 @@ class SimulationConfig:
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", {})),

View File

@ -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)
- 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
@ -146,8 +151,8 @@ class AgentAI:
# 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)
@ -171,14 +176,22 @@ class AgentAI:
# 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
@ -208,7 +221,7 @@ class AgentAI:
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)."""
@ -904,7 +917,7 @@ 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
@ -972,7 +985,7 @@ class AgentAI:
# 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.
@ -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()

View File

@ -171,20 +171,18 @@ 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
# 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))

View File

@ -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',
]

419
backend/core/goap/action.py Normal file
View File

@ -0,0 +1,419 @@
"""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,
)

View File

@ -0,0 +1,399 @@
"""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

258
backend/core/goap/debug.py Normal file
View File

@ -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()
]

185
backend/core/goap/goal.py Normal file
View File

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

411
backend/core/goap/goals.py Normal file
View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -0,0 +1,319 @@
"""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
# Profession preferences (0.5-1.5 range, higher = more preferred)
gather_preference: float = 1.0
hunt_preference: float = 1.0
trade_preference: float = 1.0
# Skill levels (0.0-1.0, higher = more skilled)
hunting_skill: float = 0.0
gathering_skill: float = 0.0
trading_skill: float = 0.0
# 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,
gather_preference=agent.personality.gather_preference,
hunt_preference=agent.personality.hunt_preference,
trade_preference=agent.personality.trade_preference,
hunting_skill=agent.skills.hunting,
gathering_skill=agent.skills.gathering,
trading_skill=agent.skills.trading,
critical_threshold=agent_config.critical_threshold,
low_threshold=0.45, # Could also be in config
)

View File

@ -40,7 +40,7 @@ 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
@ -62,8 +62,9 @@ class Order:
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."""
@ -124,6 +125,14 @@ 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.
@ -376,7 +385,7 @@ class OrderBook:
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."""

View File

@ -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
},

View File

@ -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
}

496
tools/optimize_goap.py Normal file
View File

@ -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()

View File

@ -0,0 +1,820 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GOAP Debug Visualizer - VillSim</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border-color: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-orange: #d29922;
--accent-red: #f85149;
--accent-purple: #a371f7;
--accent-cyan: #39c5cf;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: 20px;
font-weight: 600;
color: var(--accent-cyan);
}
.header-controls {
display: flex;
gap: 12px;
align-items: center;
}
.btn {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:hover {
background: var(--border-color);
border-color: var(--text-muted);
}
.btn-primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
.btn-primary:hover {
background: #4c9aff;
}
.status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-connected {
background: rgba(63, 185, 80, 0.2);
color: var(--accent-green);
}
.status-disconnected {
background: rgba(248, 81, 73, 0.2);
color: var(--accent-red);
}
.main-content {
display: grid;
grid-template-columns: 280px 1fr 400px;
height: calc(100vh - 65px);
}
.panel {
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
overflow-y: auto;
}
.panel:last-child {
border-right: none;
border-left: 1px solid var(--border-color);
}
.panel-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
position: sticky;
top: 0;
z-index: 10;
}
.panel-header h2 {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.agent-list {
padding: 8px;
}
.agent-item {
padding: 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 4px;
transition: background 0.15s ease;
}
.agent-item:hover {
background: var(--bg-tertiary);
}
.agent-item.selected {
background: rgba(88, 166, 255, 0.15);
border: 1px solid var(--accent-blue);
}
.agent-item .agent-name {
font-weight: 500;
margin-bottom: 4px;
}
.agent-item .agent-action {
font-size: 12px;
color: var(--text-secondary);
font-family: 'IBM Plex Mono', monospace;
}
.agent-item .agent-goal {
font-size: 11px;
color: var(--accent-cyan);
margin-top: 4px;
}
.center-panel {
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
.plan-view {
padding: 24px;
flex: 1;
overflow-y: auto;
}
.plan-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.plan-header h2 {
font-size: 24px;
font-weight: 600;
}
.plan-goal-badge {
padding: 6px 16px;
background: rgba(163, 113, 247, 0.2);
color: var(--accent-purple);
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.world-state-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 32px;
}
.state-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
}
.state-card .label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.state-card .value {
font-size: 24px;
font-weight: 600;
font-family: 'IBM Plex Mono', monospace;
}
.state-card .bar {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.state-card .bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.bar-thirst .bar-fill { background: var(--accent-blue); }
.bar-hunger .bar-fill { background: var(--accent-orange); }
.bar-heat .bar-fill { background: var(--accent-red); }
.bar-energy .bar-fill { background: var(--accent-green); }
.plan-visualization {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.plan-visualization h3 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.plan-steps {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.plan-step {
display: flex;
align-items: center;
gap: 8px;
}
.step-node {
padding: 12px 20px;
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
border-radius: 8px;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
font-weight: 500;
}
.step-node.current {
border-color: var(--accent-green);
background: rgba(63, 185, 80, 0.15);
color: var(--accent-green);
}
.step-arrow {
color: var(--text-muted);
font-size: 20px;
}
.goal-result {
padding: 12px 20px;
background: rgba(163, 113, 247, 0.15);
border: 2px solid var(--accent-purple);
border-radius: 8px;
color: var(--accent-purple);
font-weight: 500;
}
.no-plan {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.goals-chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
}
.goals-chart-container h3 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.chart-wrapper {
height: 300px;
}
.detail-section {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.detail-section h3 {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
}
.detail-item .label {
color: var(--text-secondary);
}
.detail-item .value {
font-family: 'IBM Plex Mono', monospace;
color: var(--text-primary);
}
.action-list {
max-height: 300px;
overflow-y: auto;
}
.action-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 4px;
font-size: 13px;
}
.action-item.valid {
background: var(--bg-tertiary);
}
.action-item.invalid {
background: transparent;
opacity: 0.5;
}
.action-item.in-plan {
background: rgba(63, 185, 80, 0.15);
border: 1px solid var(--accent-green);
}
.action-item .action-name {
flex: 1;
font-family: 'IBM Plex Mono', monospace;
}
.action-item .action-cost {
font-size: 11px;
color: var(--text-muted);
margin-left: 8px;
}
.action-item .action-order {
width: 20px;
height: 20px;
background: var(--accent-green);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: #000;
margin-right: 8px;
}
.inventory-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.inv-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 13px;
}
.inv-item .icon {
font-size: 16px;
}
.inv-item .count {
margin-left: auto;
font-family: 'IBM Plex Mono', monospace;
font-weight: 500;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.urgency-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-left: 8px;
}
.urgency-none { background: var(--accent-green); }
.urgency-low { background: var(--accent-orange); }
.urgency-high { background: var(--accent-red); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.updating {
animation: pulse 1s infinite;
}
</style>
</head>
<body>
<header class="header">
<h1>🧠 GOAP Debug Visualizer</h1>
<div class="header-controls">
<span id="turn-display">Turn 0</span>
<span id="status-badge" class="status-badge status-disconnected">Disconnected</span>
<button class="btn" onclick="refreshData()">↻ Refresh</button>
<button class="btn btn-primary" id="auto-refresh-btn" onclick="toggleAutoRefresh()">▶ Auto</button>
</div>
</header>
<main class="main-content">
<!-- Left Panel: Agent List -->
<div class="panel">
<div class="panel-header">
<h2>Agents</h2>
</div>
<div id="agent-list" class="agent-list">
<div class="loading">Loading...</div>
</div>
</div>
<!-- Center Panel: Plan Visualization -->
<div class="center-panel">
<div class="plan-view" id="plan-view">
<div class="loading">Select an agent to view GOAP details</div>
</div>
</div>
<!-- Right Panel: Details -->
<div class="panel">
<div class="panel-header">
<h2>Details</h2>
</div>
<div id="details-panel">
<div class="loading">Select an agent</div>
</div>
</div>
</main>
<script>
const API_BASE = 'http://localhost:8000/api';
let selectedAgentId = null;
let allAgentsData = [];
let autoRefreshInterval = null;
let goalsChart = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
refreshData();
});
async function refreshData() {
try {
const response = await fetch(`${API_BASE}/goap/debug`);
if (!response.ok) throw new Error('API error');
const data = await response.json();
allAgentsData = data.agents;
document.getElementById('turn-display').textContent = `Turn ${data.current_turn}`;
document.getElementById('status-badge').className = 'status-badge status-connected';
document.getElementById('status-badge').textContent = data.is_night ? '🌙 Night' : '☀️ Connected';
renderAgentList();
if (selectedAgentId) {
const agent = allAgentsData.find(a => a.agent_id === selectedAgentId);
if (agent) {
renderAgentDetails(agent);
}
}
} catch (error) {
console.error('Failed to fetch data:', error);
document.getElementById('status-badge').className = 'status-badge status-disconnected';
document.getElementById('status-badge').textContent = 'Disconnected';
}
}
function renderAgentList() {
const container = document.getElementById('agent-list');
if (allAgentsData.length === 0) {
container.innerHTML = '<div class="loading">No agents found</div>';
return;
}
container.innerHTML = allAgentsData.map(agent => `
<div class="agent-item ${agent.agent_id === selectedAgentId ? 'selected' : ''}"
onclick="selectAgent('${agent.agent_id}')">
<div class="agent-name">${agent.agent_name}</div>
<div class="agent-action">${agent.selected_action || 'No action'}</div>
<div class="agent-goal">${agent.current_plan ? '🎯 ' + agent.current_plan.goal_name : '(reactive)'}</div>
</div>
`).join('');
}
function selectAgent(agentId) {
selectedAgentId = agentId;
renderAgentList();
const agent = allAgentsData.find(a => a.agent_id === agentId);
if (agent) {
renderAgentDetails(agent);
}
}
function renderAgentDetails(agent) {
renderPlanView(agent);
renderDetailsPanel(agent);
}
function getUrgencyClass(urgency) {
if (urgency <= 0) return 'urgency-none';
if (urgency <= 1) return 'urgency-low';
return 'urgency-high';
}
function renderPlanView(agent) {
const container = document.getElementById('plan-view');
const ws = agent.world_state;
const plan = agent.current_plan;
container.innerHTML = `
<div class="plan-header">
<h2>${agent.agent_name}</h2>
${plan ? `<span class="plan-goal-badge">🎯 ${plan.goal_name}</span>` : ''}
</div>
<div class="world-state-grid">
<div class="state-card bar-thirst">
<div class="label">Thirst</div>
<div class="value">${Math.round(ws.vitals.thirst * 100)}%
<span class="urgency-indicator ${getUrgencyClass(ws.urgencies.thirst)}"></span>
</div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.thirst * 100}%"></div></div>
</div>
<div class="state-card bar-hunger">
<div class="label">Hunger</div>
<div class="value">${Math.round(ws.vitals.hunger * 100)}%
<span class="urgency-indicator ${getUrgencyClass(ws.urgencies.hunger)}"></span>
</div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.hunger * 100}%"></div></div>
</div>
<div class="state-card bar-heat">
<div class="label">Heat</div>
<div class="value">${Math.round(ws.vitals.heat * 100)}%
<span class="urgency-indicator ${getUrgencyClass(ws.urgencies.heat)}"></span>
</div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.heat * 100}%"></div></div>
</div>
<div class="state-card bar-energy">
<div class="label">Energy</div>
<div class="value">${Math.round(ws.vitals.energy * 100)}%
<span class="urgency-indicator ${getUrgencyClass(ws.urgencies.energy)}"></span>
</div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.energy * 100}%"></div></div>
</div>
</div>
<div class="plan-visualization">
<h3>Current Plan</h3>
${plan && plan.actions.length > 0 ? `
<div class="plan-steps">
${plan.actions.map((action, i) => `
<div class="plan-step">
<div class="step-node ${i === 0 ? 'current' : ''}">${action}</div>
${i < plan.actions.length - 1 ? '<span class="step-arrow"></span>' : ''}
</div>
`).join('')}
<span class="step-arrow"></span>
<div class="goal-result">✓ ${plan.goal_name}</div>
</div>
<div style="margin-top: 12px; font-size: 13px; color: var(--text-muted);">
Total Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
</div>
` : `
<div class="no-plan">
<p style="font-size: 16px; margin-bottom: 8px;">No plan - using reactive selection</p>
<p>Selected: <strong>${agent.selected_action || 'None'}</strong></p>
</div>
`}
</div>
<div class="goals-chart-container">
<h3>Goal Priorities</h3>
<div class="chart-wrapper">
<canvas id="goals-chart"></canvas>
</div>
</div>
`;
renderGoalsChart(agent);
}
function renderGoalsChart(agent) {
const ctx = document.getElementById('goals-chart');
if (!ctx) return;
// Sort goals by priority
const sortedGoals = [...agent.goals].sort((a, b) => b.priority - a.priority);
const topGoals = sortedGoals.slice(0, 10);
if (goalsChart) {
goalsChart.destroy();
}
goalsChart = new Chart(ctx, {
type: 'bar',
data: {
labels: topGoals.map(g => g.name),
datasets: [{
label: 'Priority',
data: topGoals.map(g => g.priority),
backgroundColor: topGoals.map(g => {
if (g.is_selected) return 'rgba(163, 113, 247, 0.8)';
if (g.is_satisfied) return 'rgba(63, 185, 80, 0.5)';
if (g.priority > 0) return 'rgba(88, 166, 255, 0.7)';
return 'rgba(110, 118, 129, 0.3)';
}),
borderColor: topGoals.map(g => {
if (g.is_selected) return '#a371f7';
if (g.is_satisfied) return '#3fb950';
if (g.priority > 0) return '#58a6ff';
return '#6e7681';
}),
borderWidth: 2,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
grid: { color: '#30363d' },
ticks: { color: '#8b949e' },
},
y: {
grid: { display: false },
ticks: {
color: '#e6edf3',
font: { family: 'IBM Plex Mono', size: 11 }
},
}
}
}
});
}
function renderDetailsPanel(agent) {
const container = document.getElementById('details-panel');
const ws = agent.world_state;
const validActions = agent.actions.filter(a => a.is_valid);
const inPlanActions = agent.actions.filter(a => a.is_in_plan).sort((a, b) => a.plan_order - b.plan_order);
container.innerHTML = `
<div class="detail-section">
<h3>Inventory</h3>
<div class="inventory-grid">
<div class="inv-item"><span class="icon">💧</span> Water <span class="count">${ws.inventory.water}</span></div>
<div class="inv-item"><span class="icon">🍖</span> Meat <span class="count">${ws.inventory.meat}</span></div>
<div class="inv-item"><span class="icon">🫐</span> Berries <span class="count">${ws.inventory.berries}</span></div>
<div class="inv-item"><span class="icon">🪵</span> Wood <span class="count">${ws.inventory.wood}</span></div>
<div class="inv-item"><span class="icon">🥩</span> Hide <span class="count">${ws.inventory.hide}</span></div>
<div class="inv-item"><span class="icon">📦</span> Space <span class="count">${ws.inventory.space}</span></div>
</div>
</div>
<div class="detail-section">
<h3>Economy</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Money</span>
<span class="value" style="color: var(--accent-orange)">${ws.economy.money}c</span>
</div>
<div class="detail-item">
<span class="label">Wealthy</span>
<span class="value">${ws.economy.is_wealthy ? '✓' : '✗'}</span>
</div>
</div>
</div>
<div class="detail-section">
<h3>Market Access</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Buy Water</span>
<span class="value">${ws.market.can_buy_water ? '✓' : '✗'}</span>
</div>
<div class="detail-item">
<span class="label">Buy Food</span>
<span class="value">${ws.market.can_buy_food ? '✓' : '✗'}</span>
</div>
<div class="detail-item">
<span class="label">Buy Wood</span>
<span class="value">${ws.market.can_buy_wood ? '✓' : '✗'}</span>
</div>
</div>
</div>
<div class="detail-section">
<h3>Actions (${validActions.length} valid)</h3>
<div class="action-list">
${agent.actions.map(action => `
<div class="action-item ${action.is_valid ? 'valid' : 'invalid'} ${action.is_in_plan ? 'in-plan' : ''}">
${action.is_in_plan ? `<span class="action-order">${action.plan_order + 1}</span>` : ''}
<span class="action-name">${action.name}</span>
<span class="action-cost">${action.cost >= 0 ? action.cost.toFixed(1) : '∞'}</span>
</div>
`).join('')}
</div>
</div>
`;
}
function toggleAutoRefresh() {
const btn = document.getElementById('auto-refresh-btn');
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
btn.textContent = '▶ Auto';
btn.classList.remove('btn-primary');
} else {
autoRefreshInterval = setInterval(refreshData, 500);
btn.textContent = '⏸ Stop';
btn.classList.add('btn-primary');
}
}
</script>
</body>
</html>

View File

@ -135,6 +135,7 @@
<button class="tab-btn" data-tab="resources">Resources</button>
<button class="tab-btn" data-tab="market">Market</button>
<button class="tab-btn" data-tab="agents">Agents</button>
<button class="tab-btn" data-tab="goap">🧠 GOAP</button>
</div>
</div>
<div class="stats-header-right">
@ -240,8 +241,54 @@
</div>
</div>
</div>
<!-- GOAP Tab -->
<div id="tab-goap" class="tab-panel">
<div class="goap-container">
<div class="goap-header">
<h3>Goal-Oriented Action Planning</h3>
<p class="goap-subtitle">Real-time visualization of agent decision-making</p>
</div>
<div class="goap-grid">
<div class="goap-panel goap-agents-panel">
<h4>Agents</h4>
<div id="goap-agent-list" class="goap-agent-list">
<p class="loading-text">Loading agents...</p>
</div>
</div>
<div class="goap-panel goap-plan-panel">
<h4>Current Plan</h4>
<div id="goap-plan-view" class="goap-plan-view">
<p class="no-selection-text">Select an agent to view their GOAP plan</p>
</div>
</div>
<div class="goap-panel goap-goals-panel">
<h4>Goal Priorities</h4>
<div class="chart-wrapper">
<canvas id="chart-goap-goals"></canvas>
</div>
</div>
<div class="goap-panel goap-actions-panel">
<h4>Available Actions</h4>
<div id="goap-actions-list" class="goap-actions-list">
<p class="no-selection-text">Select an agent</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="stats-footer">
<div class="controls">
<button id="btn-initialize-stats" class="btn btn-secondary" title="Reset Simulation">
<span class="btn-icon"></span> Reset
</button>
<button id="btn-step-stats" class="btn btn-primary" title="Advance one turn">
<span class="btn-icon"></span> Step
</button>
<button id="btn-auto-stats" class="btn btn-toggle" title="Toggle auto mode">
<span class="btn-icon"></span> Auto
</button>
</div>
<div class="stats-summary-bar">
<div class="summary-item">
<span class="summary-label">Turn</span>
@ -268,6 +315,11 @@
<span class="summary-value" id="stats-gini">0.00</span>
</div>
</div>
<div class="speed-control">
<label for="speed-slider-stats">Speed</label>
<input type="range" id="speed-slider-stats" min="50" max="1000" value="150" step="50">
<span id="speed-display-stats">150ms</span>
</div>
</div>
</div>
</div>

View File

@ -124,6 +124,21 @@ class SimulationAPI {
async getLogs(limit = 10) {
return await this.request(`/api/logs?limit=${limit}`);
}
// GOAP: Get debug info for all agents
async getGOAPDebug() {
return await this.request('/api/goap/debug');
}
// GOAP: Get debug info for specific agent
async getAgentGOAPDebug(agentId) {
return await this.request(`/api/goap/debug/${agentId}`);
}
// Generic GET helper (for compatibility)
async get(endpoint) {
return await this.request(`/api${endpoint}`);
}
}
// Export singleton instance

View File

@ -127,7 +127,22 @@ export default class GameScene extends Phaser.Scene {
statsGold: document.getElementById('stats-gold'),
statsAvgWealth: document.getElementById('stats-avg-wealth'),
statsGini: document.getElementById('stats-gini'),
// GOAP elements
goapAgentList: document.getElementById('goap-agent-list'),
goapPlanView: document.getElementById('goap-plan-view'),
goapActionsList: document.getElementById('goap-actions-list'),
chartGoapGoals: document.getElementById('chart-goap-goals'),
// Stats screen controls (duplicated for stats page)
btnStepStats: document.getElementById('btn-step-stats'),
btnAutoStats: document.getElementById('btn-auto-stats'),
btnInitializeStats: document.getElementById('btn-initialize-stats'),
speedSliderStats: document.getElementById('speed-slider-stats'),
speedDisplayStats: document.getElementById('speed-display-stats'),
};
// GOAP state
this.goapData = null;
this.selectedGoapAgentId = null;
}
cleanup() {
@ -163,6 +178,21 @@ export default class GameScene extends Phaser.Scene {
btnCloseStats.removeEventListener('click', this.boundHandlers.closeStats);
}
// Stats screen controls cleanup
const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats } = this.domCache;
if (btnStepStats && this.boundHandlers.step) {
btnStepStats.removeEventListener('click', this.boundHandlers.step);
}
if (btnAutoStats && this.boundHandlers.auto) {
btnAutoStats.removeEventListener('click', this.boundHandlers.auto);
}
if (btnInitializeStats && this.boundHandlers.init) {
btnInitializeStats.removeEventListener('click', this.boundHandlers.init);
}
if (speedSliderStats && this.boundHandlers.speedStats) {
speedSliderStats.removeEventListener('input', this.boundHandlers.speedStats);
}
// Destroy charts
Object.values(this.charts).forEach(chart => chart?.destroy());
this.charts = {};
@ -277,19 +307,34 @@ export default class GameScene extends Phaser.Scene {
setupUIControls() {
const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider, speedDisplay, tabButtons } = this.domCache;
const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats, speedDisplayStats } = this.domCache;
// Create bound handlers for later cleanup
this.boundHandlers.step = () => this.handleStep();
this.boundHandlers.auto = () => this.toggleAutoMode();
this.boundHandlers.init = () => this.handleInitialize();
// Speed handler that syncs both sliders
this.boundHandlers.speed = (e) => {
this.autoSpeed = parseInt(e.target.value);
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`;
if (speedSliderStats) speedSliderStats.value = this.autoSpeed;
if (this.isAutoMode) this.restartAutoMode();
};
this.boundHandlers.speedStats = (e) => {
this.autoSpeed = parseInt(e.target.value);
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`;
if (speedSlider) speedSlider.value = this.autoSpeed;
if (this.isAutoMode) this.restartAutoMode();
};
this.boundHandlers.openStats = () => this.showStatsScreen();
this.boundHandlers.closeStats = () => this.hideStatsScreen();
// Main controls
if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step);
if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto);
if (btnInitialize) btnInitialize.addEventListener('click', this.boundHandlers.init);
@ -297,6 +342,12 @@ export default class GameScene extends Phaser.Scene {
if (btnStats) btnStats.addEventListener('click', this.boundHandlers.openStats);
if (btnCloseStats) btnCloseStats.addEventListener('click', this.boundHandlers.closeStats);
// Stats screen controls (same handlers)
if (btnStepStats) btnStepStats.addEventListener('click', this.boundHandlers.step);
if (btnAutoStats) btnAutoStats.addEventListener('click', this.boundHandlers.auto);
if (btnInitializeStats) btnInitializeStats.addEventListener('click', this.boundHandlers.init);
if (speedSliderStats) speedSliderStats.addEventListener('input', this.boundHandlers.speedStats);
// Tab switching
tabButtons?.forEach(btn => {
btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
@ -371,15 +422,19 @@ export default class GameScene extends Phaser.Scene {
toggleAutoMode() {
this.isAutoMode = !this.isAutoMode;
const { btnAuto, btnStep } = this.domCache;
const { btnAuto, btnStep, btnAutoStats, btnStepStats } = this.domCache;
if (this.isAutoMode) {
btnAuto?.classList.add('active');
btnAutoStats?.classList.add('active');
btnStep?.setAttribute('disabled', 'true');
btnStepStats?.setAttribute('disabled', 'true');
this.startAutoMode();
} else {
btnAuto?.classList.remove('active');
btnAutoStats?.classList.remove('active');
btnStep?.removeAttribute('disabled');
btnStepStats?.removeAttribute('disabled');
this.stopAutoMode();
}
}
@ -741,6 +796,15 @@ export default class GameScene extends Phaser.Scene {
<span class="action-label">Current Action</span>
<div>${actionData.icon} ${action.message || actionData.verb}</div>
</div>
<div class="agent-goap-info" id="agent-goap-section" data-agent-id="${agentData.id}">
<h5 class="subsection-title" style="display: flex; align-items: center; gap: 6px;">
🧠 GOAP Plan
<button class="btn-mini" onclick="window.villsimGame.scene.scenes[1].loadAgentGOAP('${agentData.id}')" style="font-size: 0.6rem; padding: 2px 6px;"></button>
</h5>
<div id="agent-goap-content" style="font-size: 0.75rem; color: var(--text-muted);">
Click to load GOAP info
</div>
</div>
<h5 class="subsection-title">Personal Log</h5>
<div class="agent-log">
${renderActionLog()}
@ -1023,6 +1087,7 @@ export default class GameScene extends Phaser.Scene {
case 'resources': this.renderResourceCharts(); break;
case 'market': this.renderMarketCharts(); break;
case 'agents': this.renderAgentStatsCharts(); break;
case 'goap': this.fetchAndRenderGOAP(); break;
}
}
@ -1627,6 +1692,291 @@ export default class GameScene extends Phaser.Scene {
};
}
// =================================
// GOAP Visualization Methods
// =================================
async loadAgentGOAP(agentId) {
const contentEl = document.getElementById('agent-goap-content');
if (!contentEl) return;
contentEl.innerHTML = '<span style="color: var(--text-muted);">Loading...</span>';
try {
const data = await api.getAgentGOAPDebug(agentId);
const plan = data.current_plan;
if (plan && plan.actions.length > 0) {
contentEl.innerHTML = `
<div style="margin-bottom: 4px;">
<strong style="color: var(--accent-sapphire);">Goal:</strong> ${plan.goal_name}
</div>
<div style="font-family: var(--font-mono); font-size: 0.7rem;">
${plan.actions.map((a, i) =>
`<span style="${i === 0 ? 'color: var(--accent-emerald);' : ''}">${a}</span>`
).join(' → ')}
</div>
<div style="margin-top: 4px; color: var(--text-muted); font-size: 0.65rem;">
Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
</div>
`;
} else {
contentEl.innerHTML = `
<div style="color: var(--text-muted);">
No plan (reactive mode)<br>
<span style="color: var(--text-primary);">${data.selected_action || 'No action'}</span>
</div>
`;
}
} catch (error) {
console.error('Failed to load GOAP info:', error);
contentEl.innerHTML = '<span style="color: var(--accent-ruby);">Failed to load</span>';
}
}
async fetchAndRenderGOAP() {
try {
const response = await api.get('/goap/debug');
this.goapData = response;
this.renderGOAPAgentList();
// If we have a selected agent, render their details
if (this.selectedGoapAgentId) {
const agent = this.goapData.agents.find(a => a.agent_id === this.selectedGoapAgentId);
if (agent) {
this.renderGOAPAgentDetails(agent);
}
}
} catch (error) {
console.error('Failed to fetch GOAP data:', error);
const { goapAgentList } = this.domCache;
if (goapAgentList) {
goapAgentList.innerHTML = '<p class="loading-text">Failed to load GOAP data. Make sure the server is running.</p>';
}
}
}
renderGOAPAgentList() {
const { goapAgentList } = this.domCache;
if (!goapAgentList || !this.goapData) return;
if (this.goapData.agents.length === 0) {
goapAgentList.innerHTML = '<p class="loading-text">No agents found</p>';
return;
}
goapAgentList.innerHTML = this.goapData.agents.map(agent => `
<div class="goap-agent-item ${agent.agent_id === this.selectedGoapAgentId ? 'selected' : ''}"
data-agent-id="${agent.agent_id}">
<div class="agent-name">${agent.agent_name}</div>
<div class="agent-action">${agent.selected_action || 'No action'}</div>
<div class="agent-goal">${agent.current_plan ? '🎯 ' + agent.current_plan.goal_name : '(reactive)'}</div>
</div>
`).join('');
// Add click handlers
goapAgentList.querySelectorAll('.goap-agent-item').forEach(item => {
item.addEventListener('click', () => {
this.selectGoapAgent(item.dataset.agentId);
});
});
}
selectGoapAgent(agentId) {
this.selectedGoapAgentId = agentId;
// Update selection styling
const { goapAgentList } = this.domCache;
if (goapAgentList) {
goapAgentList.querySelectorAll('.goap-agent-item').forEach(item => {
item.classList.toggle('selected', item.dataset.agentId === agentId);
});
}
// Render details
if (this.goapData) {
const agent = this.goapData.agents.find(a => a.agent_id === agentId);
if (agent) {
this.renderGOAPAgentDetails(agent);
}
}
}
renderGOAPAgentDetails(agent) {
this.renderGOAPPlanView(agent);
this.renderGOAPActionsList(agent);
this.renderGOAPGoalsChart(agent);
}
getUrgencyClass(urgency) {
if (urgency <= 0) return 'none';
if (urgency <= 1) return 'low';
return 'high';
}
renderGOAPPlanView(agent) {
const { goapPlanView } = this.domCache;
if (!goapPlanView) return;
const ws = agent.world_state;
const plan = agent.current_plan;
goapPlanView.innerHTML = `
<div class="goap-world-state">
<div class="goap-stat-card thirst">
<div class="label">Thirst</div>
<div class="value">${Math.round(ws.vitals.thirst * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.thirst)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.thirst * 100}%"></div></div>
</div>
<div class="goap-stat-card hunger">
<div class="label">Hunger</div>
<div class="value">${Math.round(ws.vitals.hunger * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.hunger)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.hunger * 100}%"></div></div>
</div>
<div class="goap-stat-card heat">
<div class="label">Heat</div>
<div class="value">${Math.round(ws.vitals.heat * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.heat)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.heat * 100}%"></div></div>
</div>
<div class="goap-stat-card energy">
<div class="label">Energy</div>
<div class="value">${Math.round(ws.vitals.energy * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.energy)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.energy * 100}%"></div></div>
</div>
</div>
<div class="goap-plan-steps">
<h5>Current Plan</h5>
${plan && plan.actions.length > 0 ? `
<div class="goap-plan-flow">
${plan.actions.map((action, i) => `
<span class="goap-step-node ${i === 0 ? 'current' : ''}">${action}</span>
${i < plan.actions.length - 1 ? '<span class="goap-step-arrow">→</span>' : ''}
`).join('')}
<span class="goap-step-arrow"></span>
<span class="goap-goal-result"> ${plan.goal_name}</span>
</div>
<div style="margin-top: 8px; font-size: 0.75rem; color: var(--text-muted);">
Total Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
</div>
` : `
<div style="color: var(--text-muted); font-size: 0.85rem;">
No plan - using reactive selection<br>
Selected: <strong>${agent.selected_action || 'None'}</strong>
</div>
`}
</div>
<div style="margin-top: 12px;">
<h5 style="font-size: 0.7rem; color: var(--text-muted); margin-bottom: 8px;">INVENTORY</h5>
<div class="goap-inventory">
<div class="goap-inv-item">💧<span class="count">${ws.inventory.water}</span></div>
<div class="goap-inv-item">🍖<span class="count">${ws.inventory.meat}</span></div>
<div class="goap-inv-item">🫐<span class="count">${ws.inventory.berries}</span></div>
<div class="goap-inv-item">🪵<span class="count">${ws.inventory.wood}</span></div>
<div class="goap-inv-item">🥩<span class="count">${ws.inventory.hide}</span></div>
<div class="goap-inv-item">📦<span class="count">${ws.inventory.space}</span></div>
</div>
</div>
<div style="margin-top: 12px; display: flex; gap: 16px; font-size: 0.8rem;">
<span style="color: var(--accent-gold);">💰 ${ws.economy.money}c</span>
<span style="color: var(--text-muted);">Wealthy: ${ws.economy.is_wealthy ? '✓' : '✗'}</span>
</div>
`;
}
renderGOAPActionsList(agent) {
const { goapActionsList } = this.domCache;
if (!goapActionsList) return;
// Sort: plan actions first, then valid, then invalid
const sortedActions = [...agent.actions].sort((a, b) => {
if (a.is_in_plan && !b.is_in_plan) return -1;
if (!a.is_in_plan && b.is_in_plan) return 1;
if (a.is_in_plan && b.is_in_plan) return a.plan_order - b.plan_order;
if (a.is_valid && !b.is_valid) return -1;
if (!a.is_valid && b.is_valid) return 1;
return (a.cost || 999) - (b.cost || 999);
});
goapActionsList.innerHTML = sortedActions.map(action => `
<div class="goap-action-item ${action.is_valid ? 'valid' : 'invalid'} ${action.is_in_plan ? 'in-plan' : ''}">
${action.is_in_plan ? `<span class="action-order">${action.plan_order + 1}</span>` : ''}
<span class="action-name">${action.name}</span>
<span class="action-cost">${action.cost >= 0 ? action.cost.toFixed(1) : '∞'}</span>
</div>
`).join('');
}
renderGOAPGoalsChart(agent) {
const { chartGoapGoals } = this.domCache;
if (!chartGoapGoals) return;
// Sort goals by priority and take top 10
const sortedGoals = [...agent.goals]
.sort((a, b) => b.priority - a.priority)
.slice(0, 10);
// Destroy existing chart
if (this.charts.goapGoals) {
this.charts.goapGoals.destroy();
}
this.charts.goapGoals = new Chart(chartGoapGoals, {
type: 'bar',
data: {
labels: sortedGoals.map(g => g.name),
datasets: [{
label: 'Priority',
data: sortedGoals.map(g => g.priority),
backgroundColor: sortedGoals.map(g => {
if (g.is_selected) return 'rgba(139, 111, 192, 0.8)';
if (g.is_satisfied) return 'rgba(74, 156, 109, 0.5)';
if (g.priority > 0) return 'rgba(90, 140, 200, 0.7)';
return 'rgba(107, 101, 96, 0.3)';
}),
borderColor: sortedGoals.map(g => {
if (g.is_selected) return '#8b6fc0';
if (g.is_satisfied) return '#4a9c6d';
if (g.priority > 0) return '#5a8cc8';
return '#6b6560';
}),
borderWidth: 2,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
title: {
display: true,
text: 'Goal Priorities',
color: '#e8e4dc',
font: { family: "'Crimson Pro', serif", size: 14 },
},
},
scales: {
x: {
beginAtZero: true,
grid: { color: 'rgba(58, 67, 89, 0.3)' },
ticks: { color: '#6b6560' },
},
y: {
grid: { display: false },
ticks: {
color: '#e8e4dc',
font: { family: "'JetBrains Mono', monospace", size: 10 }
},
}
}
}
});
}
update(time, delta) {
// Minimal update loop - no heavy operations here
}

View File

@ -613,7 +613,8 @@ body {
font-weight: 500;
}
#speed-slider {
#speed-slider,
#speed-slider-stats {
width: 120px;
height: 4px;
-webkit-appearance: none;
@ -623,7 +624,8 @@ body {
cursor: pointer;
}
#speed-slider::-webkit-slider-thumb {
#speed-slider::-webkit-slider-thumb,
#speed-slider-stats::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
@ -633,7 +635,8 @@ body {
cursor: pointer;
}
#speed-display {
#speed-display,
#speed-display-stats {
font-family: var(--font-mono);
min-width: 50px;
}
@ -939,16 +942,21 @@ body {
/* Stats Footer */
.stats-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-lg);
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
height: 56px;
}
.stats-summary-bar {
display: flex;
justify-content: center;
gap: var(--space-xl);
flex: 1;
}
.summary-item {
@ -1049,5 +1057,375 @@ body {
flex-wrap: wrap;
gap: var(--space-md);
}
.stats-footer {
flex-wrap: wrap;
height: auto;
gap: var(--space-sm);
padding: var(--space-sm);
}
.stats-footer .controls {
order: 1;
width: auto;
}
.stats-footer .stats-summary-bar {
order: 2;
width: 100%;
}
.stats-footer .speed-control {
order: 3;
width: auto;
}
}
/* =================================
GOAP Visualization Styles
================================= */
.goap-container {
padding: var(--space-lg);
height: 100%;
display: flex;
flex-direction: column;
}
.goap-header {
margin-bottom: var(--space-lg);
}
.goap-header h3 {
font-size: 1.5rem;
color: var(--accent-sapphire);
margin-bottom: var(--space-xs);
}
.goap-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
}
.goap-grid {
display: grid;
grid-template-columns: 250px 1fr 300px;
grid-template-rows: 1fr 1fr;
gap: var(--space-md);
flex: 1;
min-height: 0;
}
.goap-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-md);
display: flex;
flex-direction: column;
overflow: hidden;
}
.goap-panel h4 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: var(--space-md);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--border-color);
}
.goap-agents-panel {
grid-row: span 2;
}
.goap-plan-panel {
grid-column: 2;
}
.goap-goals-panel {
grid-column: 3;
grid-row: span 2;
}
.goap-actions-panel {
grid-column: 2;
}
.goap-agent-list {
flex: 1;
overflow-y: auto;
}
.goap-agent-item {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
margin-bottom: var(--space-xs);
cursor: pointer;
transition: background 0.15s ease;
}
.goap-agent-item:hover {
background: var(--bg-hover);
}
.goap-agent-item.selected {
background: rgba(90, 140, 200, 0.2);
border-left: 3px solid var(--accent-sapphire);
}
.goap-agent-item .agent-name {
font-weight: 600;
margin-bottom: 2px;
}
.goap-agent-item .agent-action {
font-size: 0.75rem;
font-family: var(--font-mono);
color: var(--text-secondary);
}
.goap-agent-item .agent-goal {
font-size: 0.7rem;
color: var(--accent-sapphire);
margin-top: 2px;
}
.goap-plan-view {
flex: 1;
overflow-y: auto;
}
.goap-world-state {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.goap-stat-card {
background: var(--bg-elevated);
border-radius: var(--radius-sm);
padding: var(--space-sm);
text-align: center;
}
.goap-stat-card .label {
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
}
.goap-stat-card .value {
font-size: 1.1rem;
font-family: var(--font-mono);
font-weight: 600;
}
.goap-stat-card .bar {
height: 3px;
background: var(--bg-deep);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.goap-stat-card .bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.goap-stat-card.thirst .bar-fill { background: var(--stat-thirst); }
.goap-stat-card.hunger .bar-fill { background: var(--stat-hunger); }
.goap-stat-card.heat .bar-fill { background: var(--stat-heat); }
.goap-stat-card.energy .bar-fill { background: var(--stat-energy); }
.goap-plan-steps {
background: var(--bg-elevated);
border-radius: var(--radius-sm);
padding: var(--space-md);
margin-bottom: var(--space-md);
}
.goap-plan-steps h5 {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: var(--space-sm);
}
.goap-plan-flow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.goap-step-node {
padding: var(--space-sm) var(--space-md);
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 0.85rem;
}
.goap-step-node.current {
border-color: var(--accent-emerald);
background: rgba(74, 156, 109, 0.15);
color: var(--accent-emerald);
}
.goap-step-arrow {
color: var(--text-muted);
font-size: 1.2rem;
}
.goap-goal-result {
padding: var(--space-sm) var(--space-md);
background: rgba(139, 111, 192, 0.15);
border: 2px solid #8b6fc0;
border-radius: var(--radius-sm);
color: #8b6fc0;
font-weight: 600;
font-size: 0.85rem;
}
.goap-inventory {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-xs);
}
.goap-inv-item {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--bg-elevated);
border-radius: var(--radius-sm);
font-size: 0.75rem;
}
.goap-inv-item .count {
margin-left: auto;
font-family: var(--font-mono);
font-weight: 500;
}
.goap-actions-list {
flex: 1;
overflow-y: auto;
}
.goap-action-item {
display: flex;
align-items: center;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
margin-bottom: 2px;
font-size: 0.8rem;
}
.goap-action-item.valid {
background: var(--bg-elevated);
}
.goap-action-item.invalid {
opacity: 0.4;
}
.goap-action-item.in-plan {
background: rgba(74, 156, 109, 0.15);
border-left: 3px solid var(--accent-emerald);
}
.goap-action-item .action-name {
flex: 1;
font-family: var(--font-mono);
}
.goap-action-item .action-cost {
font-size: 0.7rem;
color: var(--text-muted);
}
.goap-action-item .action-order {
width: 18px;
height: 18px;
background: var(--accent-emerald);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 700;
color: var(--bg-deep);
margin-right: var(--space-sm);
}
.no-selection-text, .loading-text {
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
padding: var(--space-lg);
}
.btn-mini {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
font-family: inherit;
transition: all 0.15s ease;
}
.btn-mini:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.agent-goap-info {
margin-top: var(--space-sm);
padding: var(--space-sm);
background: var(--bg-deep);
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
}
.goap-urgency {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: 4px;
}
.goap-urgency.none { background: var(--accent-emerald); }
.goap-urgency.low { background: var(--accent-gold); }
.goap-urgency.high { background: var(--accent-ruby); }
@media (max-width: 1400px) {
.goap-grid {
grid-template-columns: 200px 1fr 250px;
}
}
@media (max-width: 1000px) {
.goap-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.goap-agents-panel,
.goap-goals-panel {
grid-row: auto;
}
.goap-panel {
max-height: 300px;
}
}