[new] add goap agents
This commit is contained in:
parent
67dc007283
commit
308f738c37
@ -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),
|
||||
}
|
||||
|
||||
|
||||
@ -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", {})),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -171,21 +171,19 @@ 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
|
||||
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,
|
||||
)
|
||||
# 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))
|
||||
|
||||
|
||||
39
backend/core/goap/__init__.py
Normal file
39
backend/core/goap/__init__.py
Normal 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',
|
||||
]
|
||||
|
||||
381
backend/core/goap/action.py
Normal file
381
backend/core/goap/action.py
Normal file
@ -0,0 +1,381 @@
|
||||
"""GOAP Action definitions.
|
||||
|
||||
Actions are the building blocks of plans. Each action has:
|
||||
- Preconditions: What must be true for the action to be valid
|
||||
- Effects: How the action changes the world state
|
||||
- Cost: How expensive the action is (for planning)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional, TYPE_CHECKING
|
||||
|
||||
from backend.domain.action import ActionType
|
||||
from backend.domain.resources import ResourceType
|
||||
|
||||
from .world_state import WorldState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.domain.agent import Agent
|
||||
from backend.core.market import OrderBook
|
||||
|
||||
|
||||
@dataclass
|
||||
class GOAPAction:
|
||||
"""A GOAP action that can be part of a plan.
|
||||
|
||||
Actions transform the world state. The planner uses preconditions
|
||||
and effects to search for valid action sequences.
|
||||
|
||||
Attributes:
|
||||
name: Human-readable name
|
||||
action_type: The underlying ActionType to execute
|
||||
target_resource: Optional resource this action targets
|
||||
preconditions: Function that checks if action is valid in a state
|
||||
effects: Function that returns the expected effects on state
|
||||
cost: Function that calculates action cost (lower = preferred)
|
||||
get_decision_params: Function to get parameters for AIDecision
|
||||
"""
|
||||
name: str
|
||||
action_type: ActionType
|
||||
target_resource: Optional[ResourceType] = None
|
||||
|
||||
# Functions that evaluate in context of world state
|
||||
preconditions: Callable[[WorldState], bool] = field(default=lambda s: True)
|
||||
effects: Callable[[WorldState], dict] = field(default=lambda s: {})
|
||||
cost: Callable[[WorldState], float] = field(default=lambda s: 1.0)
|
||||
|
||||
# For generating the actual decision
|
||||
get_decision_params: Optional[Callable[[WorldState, "Agent", "OrderBook"], dict]] = None
|
||||
|
||||
def is_valid(self, state: WorldState) -> bool:
|
||||
"""Check if this action can be performed in the given state."""
|
||||
return self.preconditions(state)
|
||||
|
||||
def apply(self, state: WorldState) -> WorldState:
|
||||
"""Apply this action's effects to a state, returning a new state.
|
||||
|
||||
This is used by the planner for forward search.
|
||||
"""
|
||||
new_state = state.copy()
|
||||
effects = self.effects(state)
|
||||
|
||||
for key, value in effects.items():
|
||||
if hasattr(new_state, key):
|
||||
if isinstance(value, (int, float)):
|
||||
# For numeric values, handle both absolute and relative changes
|
||||
current = getattr(new_state, key)
|
||||
if isinstance(current, bool):
|
||||
setattr(new_state, key, bool(value))
|
||||
else:
|
||||
setattr(new_state, key, value)
|
||||
else:
|
||||
setattr(new_state, key, value)
|
||||
|
||||
# Recalculate urgencies
|
||||
new_state._calculate_urgencies()
|
||||
|
||||
return new_state
|
||||
|
||||
def get_cost(self, state: WorldState) -> float:
|
||||
"""Get the cost of this action in the given state."""
|
||||
return self.cost(state)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
resource = f"({self.target_resource.value})" if self.target_resource else ""
|
||||
return f"GOAPAction({self.name}{resource})"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.name, self.action_type, self.target_resource))
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if not isinstance(other, GOAPAction):
|
||||
return False
|
||||
return (self.name == other.name and
|
||||
self.action_type == other.action_type and
|
||||
self.target_resource == other.target_resource)
|
||||
|
||||
|
||||
def create_consume_action(
|
||||
resource_type: ResourceType,
|
||||
stat_name: str,
|
||||
stat_increase: float,
|
||||
secondary_stat: Optional[str] = None,
|
||||
secondary_increase: float = 0.0,
|
||||
) -> GOAPAction:
|
||||
"""Factory for creating consume resource actions."""
|
||||
count_name = f"{resource_type.value}_count" if resource_type != ResourceType.BERRIES else "berries_count"
|
||||
if resource_type == ResourceType.MEAT:
|
||||
count_name = "meat_count"
|
||||
elif resource_type == ResourceType.WATER:
|
||||
count_name = "water_count"
|
||||
|
||||
# Map stat name to pct name
|
||||
pct_name = f"{stat_name}_pct"
|
||||
secondary_pct = f"{secondary_stat}_pct" if secondary_stat else None
|
||||
|
||||
def preconditions(state: WorldState) -> bool:
|
||||
count = getattr(state, count_name, 0)
|
||||
return count > 0
|
||||
|
||||
def effects(state: WorldState) -> dict:
|
||||
result = {}
|
||||
current = getattr(state, pct_name)
|
||||
result[pct_name] = min(1.0, current + stat_increase)
|
||||
|
||||
if secondary_pct:
|
||||
current_sec = getattr(state, secondary_pct)
|
||||
result[secondary_pct] = min(1.0, current_sec + secondary_increase)
|
||||
|
||||
# Reduce resource count
|
||||
current_count = getattr(state, count_name)
|
||||
result[count_name] = max(0, current_count - 1)
|
||||
|
||||
# Update food count if consuming food
|
||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
||||
result["food_count"] = max(0, state.food_count - 1)
|
||||
|
||||
return result
|
||||
|
||||
def cost(state: WorldState) -> float:
|
||||
# Consuming is very cheap - 0 energy cost
|
||||
return 0.5
|
||||
|
||||
return GOAPAction(
|
||||
name=f"Consume {resource_type.value}",
|
||||
action_type=ActionType.CONSUME,
|
||||
target_resource=resource_type,
|
||||
preconditions=preconditions,
|
||||
effects=effects,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
|
||||
def create_gather_action(
|
||||
action_type: ActionType,
|
||||
resource_type: ResourceType,
|
||||
energy_cost: float,
|
||||
expected_output: int,
|
||||
success_chance: float = 1.0,
|
||||
) -> GOAPAction:
|
||||
"""Factory for creating resource gathering actions."""
|
||||
count_name = f"{resource_type.value}_count"
|
||||
if resource_type == ResourceType.BERRIES:
|
||||
count_name = "berries_count"
|
||||
elif resource_type == ResourceType.MEAT:
|
||||
count_name = "meat_count"
|
||||
|
||||
def preconditions(state: WorldState) -> bool:
|
||||
# Need enough energy and inventory space
|
||||
energy_needed = abs(energy_cost) / 50.0 # Convert to percentage
|
||||
return state.energy_pct >= energy_needed + 0.05 and state.inventory_space > 0
|
||||
|
||||
def effects(state: WorldState) -> dict:
|
||||
result = {}
|
||||
|
||||
# Spend energy
|
||||
energy_spent = abs(energy_cost) / 50.0
|
||||
result["energy_pct"] = max(0, state.energy_pct - energy_spent)
|
||||
|
||||
# Gain resources (adjusted for success chance)
|
||||
effective_output = int(expected_output * success_chance)
|
||||
current = getattr(state, count_name)
|
||||
result[count_name] = current + effective_output
|
||||
|
||||
# Update food count if gathering food
|
||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
||||
result["food_count"] = state.food_count + effective_output
|
||||
|
||||
# Update inventory space
|
||||
result["inventory_space"] = max(0, state.inventory_space - effective_output)
|
||||
|
||||
return result
|
||||
|
||||
def cost(state: WorldState) -> float:
|
||||
# Calculate cost based on efficiency (energy per unit of food)
|
||||
food_per_action = expected_output * success_chance
|
||||
if food_per_action > 0:
|
||||
base_cost = abs(energy_cost) / food_per_action * 0.5
|
||||
else:
|
||||
base_cost = abs(energy_cost) / 5.0
|
||||
|
||||
# Adjust for success chance (penalize unreliable actions slightly)
|
||||
if success_chance < 1.0:
|
||||
base_cost *= 1.0 + (1.0 - success_chance) * 0.3
|
||||
|
||||
# Mild personality adjustments (shouldn't dominate the cost)
|
||||
if action_type == ActionType.GATHER:
|
||||
# Cautious agents slightly prefer gathering
|
||||
base_cost *= (0.9 + state.risk_tolerance * 0.2)
|
||||
|
||||
return base_cost
|
||||
|
||||
return GOAPAction(
|
||||
name=f"{action_type.value}",
|
||||
action_type=action_type,
|
||||
target_resource=resource_type,
|
||||
preconditions=preconditions,
|
||||
effects=effects,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
|
||||
def create_buy_action(resource_type: ResourceType) -> GOAPAction:
|
||||
"""Factory for creating market buy actions."""
|
||||
can_buy_name = f"can_buy_{resource_type.value}"
|
||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
||||
can_buy_name = "can_buy_food" # Simplified - we check specific later
|
||||
|
||||
count_name = f"{resource_type.value}_count"
|
||||
if resource_type == ResourceType.BERRIES:
|
||||
count_name = "berries_count"
|
||||
elif resource_type == ResourceType.MEAT:
|
||||
count_name = "meat_count"
|
||||
|
||||
price_name = f"{resource_type.value}_market_price"
|
||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
||||
price_name = "food_market_price"
|
||||
|
||||
def preconditions(state: WorldState) -> bool:
|
||||
# Check specific availability
|
||||
if resource_type == ResourceType.MEAT:
|
||||
can_buy = state.can_buy_meat
|
||||
elif resource_type == ResourceType.BERRIES:
|
||||
can_buy = state.can_buy_berries
|
||||
else:
|
||||
can_buy = getattr(state, f"can_buy_{resource_type.value}", False)
|
||||
|
||||
return can_buy and state.inventory_space > 0
|
||||
|
||||
def effects(state: WorldState) -> dict:
|
||||
result = {}
|
||||
|
||||
# Get price
|
||||
if resource_type == ResourceType.MEAT:
|
||||
price = state.food_market_price
|
||||
elif resource_type == ResourceType.BERRIES:
|
||||
price = state.food_market_price
|
||||
else:
|
||||
price = getattr(state, price_name, 10)
|
||||
|
||||
# Spend money
|
||||
result["money"] = state.money - price
|
||||
|
||||
# Gain resource
|
||||
current = getattr(state, count_name)
|
||||
result[count_name] = current + 1
|
||||
|
||||
# Update food count if buying food
|
||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
||||
result["food_count"] = state.food_count + 1
|
||||
|
||||
# Spend small energy
|
||||
result["energy_pct"] = max(0, state.energy_pct - 0.02)
|
||||
|
||||
# Update inventory
|
||||
result["inventory_space"] = max(0, state.inventory_space - 1)
|
||||
|
||||
return result
|
||||
|
||||
def cost(state: WorldState) -> float:
|
||||
# Trading cost is low (1 energy)
|
||||
base_cost = 0.5
|
||||
|
||||
# Market-oriented agents prefer buying
|
||||
base_cost *= (1.5 - state.market_affinity)
|
||||
|
||||
# Check if it's a good deal
|
||||
if resource_type == ResourceType.MEAT:
|
||||
price = state.food_market_price
|
||||
elif resource_type == ResourceType.BERRIES:
|
||||
price = state.food_market_price
|
||||
else:
|
||||
price = getattr(state, price_name, 100)
|
||||
|
||||
# Higher price = higher cost (scaled for 100-500g price range)
|
||||
# At fair value (~150g), multiplier is ~1.5x
|
||||
# At min price (100g), multiplier is ~1.33x
|
||||
base_cost *= (1.0 + price / 300.0)
|
||||
|
||||
return base_cost
|
||||
|
||||
return GOAPAction(
|
||||
name=f"Buy {resource_type.value}",
|
||||
action_type=ActionType.TRADE,
|
||||
target_resource=resource_type,
|
||||
preconditions=preconditions,
|
||||
effects=effects,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
|
||||
def create_rest_action() -> GOAPAction:
|
||||
"""Create the rest action."""
|
||||
def preconditions(state: WorldState) -> bool:
|
||||
return state.energy_pct < 0.9 # Only rest if not full
|
||||
|
||||
def effects(state: WorldState) -> dict:
|
||||
# Rest restores energy (12 out of 50 = 0.24)
|
||||
return {
|
||||
"energy_pct": min(1.0, state.energy_pct + 0.24),
|
||||
}
|
||||
|
||||
def cost(state: WorldState) -> float:
|
||||
# Resting is cheap but we prefer productive actions
|
||||
return 2.0
|
||||
|
||||
return GOAPAction(
|
||||
name="Rest",
|
||||
action_type=ActionType.REST,
|
||||
preconditions=preconditions,
|
||||
effects=effects,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
|
||||
def create_build_fire_action() -> GOAPAction:
|
||||
"""Create the build fire action."""
|
||||
def preconditions(state: WorldState) -> bool:
|
||||
return state.wood_count > 0 and state.energy_pct >= 0.1
|
||||
|
||||
def effects(state: WorldState) -> dict:
|
||||
return {
|
||||
"heat_pct": min(1.0, state.heat_pct + 0.20), # Fire gives 20 heat out of 100
|
||||
"wood_count": max(0, state.wood_count - 1),
|
||||
"energy_pct": max(0, state.energy_pct - 0.08), # 4 energy cost
|
||||
}
|
||||
|
||||
def cost(state: WorldState) -> float:
|
||||
# Building fire is relatively cheap when we have wood
|
||||
return 1.5
|
||||
|
||||
return GOAPAction(
|
||||
name="Build Fire",
|
||||
action_type=ActionType.BUILD_FIRE,
|
||||
target_resource=ResourceType.WOOD,
|
||||
preconditions=preconditions,
|
||||
effects=effects,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
|
||||
def create_sleep_action() -> GOAPAction:
|
||||
"""Create the sleep action (for night)."""
|
||||
def preconditions(state: WorldState) -> bool:
|
||||
return state.is_night
|
||||
|
||||
def effects(state: WorldState) -> dict:
|
||||
return {
|
||||
"energy_pct": 1.0, # Full energy restore
|
||||
}
|
||||
|
||||
def cost(state: WorldState) -> float:
|
||||
return 0.0 # Sleep is mandatory at night
|
||||
|
||||
return GOAPAction(
|
||||
name="Sleep",
|
||||
action_type=ActionType.SLEEP,
|
||||
preconditions=preconditions,
|
||||
effects=effects,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
362
backend/core/goap/actions.py
Normal file
362
backend/core/goap/actions.py
Normal file
@ -0,0 +1,362 @@
|
||||
"""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
|
||||
|
||||
# Risk-tolerant agents prefer hunting
|
||||
# Range: 0.85 (high risk tolerance) to 1.15 (low risk tolerance)
|
||||
risk_modifier = 1.0 + (0.5 - state.risk_tolerance) * 0.3
|
||||
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
|
||||
base_cost = 1.0
|
||||
|
||||
# Hoarders reluctant to sell
|
||||
base_cost *= (0.5 + state.hoarding_rate)
|
||||
|
||||
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
258
backend/core/goap/debug.py
Normal 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
185
backend/core/goap/goal.py
Normal 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
411
backend/core/goap/goals.py
Normal 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
|
||||
|
||||
411
backend/core/goap/goap_ai.py
Normal file
411
backend/core/goap/goap_ai.py
Normal 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()
|
||||
|
||||
335
backend/core/goap/planner.py
Normal file
335
backend/core/goap/planner.py
Normal 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)
|
||||
|
||||
303
backend/core/goap/world_state.py
Normal file
303
backend/core/goap/world_state.py
Normal file
@ -0,0 +1,303 @@
|
||||
"""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
|
||||
|
||||
# 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,
|
||||
critical_threshold=agent_config.critical_threshold,
|
||||
low_threshold=0.45, # Could also be in config
|
||||
)
|
||||
|
||||
@ -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."""
|
||||
|
||||
31
config.json
31
config.json
@ -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
|
||||
},
|
||||
|
||||
79
config_goap_optimized.json
Normal file
79
config_goap_optimized.json
Normal 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
496
tools/optimize_goap.py
Normal 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()
|
||||
|
||||
820
web_frontend/goap_debug.html
Normal file
820
web_frontend/goap_debug.html
Normal 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>
|
||||
|
||||
@ -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,6 +241,41 @@
|
||||
</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="stats-summary-bar">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -127,7 +127,16 @@ 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'),
|
||||
};
|
||||
|
||||
// GOAP state
|
||||
this.goapData = null;
|
||||
this.selectedGoapAgentId = null;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@ -741,6 +750,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 +1041,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 +1646,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
|
||||
}
|
||||
|
||||
@ -1051,3 +1051,351 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user