Compare commits
No commits in common. "goap" and "master" have entirely different histories.
@ -315,107 +315,3 @@ def load_config_from_file():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to load config: {str(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,10 +109,7 @@ class EconomyConfig:
|
|||||||
"""
|
"""
|
||||||
# How much agents value money vs energy
|
# How much agents value money vs energy
|
||||||
# Higher = agents see money as more valuable (trade more)
|
# Higher = agents see money as more valuable (trade more)
|
||||||
energy_to_money_ratio: float = 150 # 1 energy ≈ 150 coins
|
energy_to_money_ratio: float = 1.5 # 1 energy ≈ 1.5 coins
|
||||||
|
|
||||||
# Minimum price floor for any market transaction
|
|
||||||
min_price: int = 100
|
|
||||||
|
|
||||||
# How strongly agents desire wealth (0-1)
|
# How strongly agents desire wealth (0-1)
|
||||||
# Higher = agents will prioritize building wealth
|
# Higher = agents will prioritize building wealth
|
||||||
@ -124,33 +121,13 @@ class EconomyConfig:
|
|||||||
buy_efficiency_threshold: float = 0.7
|
buy_efficiency_threshold: float = 0.7
|
||||||
|
|
||||||
# Minimum wealth target - agents want at least this much money
|
# Minimum wealth target - agents want at least this much money
|
||||||
min_wealth_target: int = 5000
|
min_wealth_target: int = 50
|
||||||
|
|
||||||
# Price adjustment limits
|
# Price adjustment limits
|
||||||
max_price_markup: float = 2.0 # Maximum price = 2x base value
|
max_price_markup: float = 2.0 # Maximum price = 2x base value
|
||||||
min_price_discount: float = 0.5 # Minimum price = 50% of 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
|
@dataclass
|
||||||
class SimulationConfig:
|
class SimulationConfig:
|
||||||
"""Master configuration containing all sub-configs."""
|
"""Master configuration containing all sub-configs."""
|
||||||
@ -160,7 +137,6 @@ class SimulationConfig:
|
|||||||
world: WorldConfig = field(default_factory=WorldConfig)
|
world: WorldConfig = field(default_factory=WorldConfig)
|
||||||
market: MarketConfig = field(default_factory=MarketConfig)
|
market: MarketConfig = field(default_factory=MarketConfig)
|
||||||
economy: EconomyConfig = field(default_factory=EconomyConfig)
|
economy: EconomyConfig = field(default_factory=EconomyConfig)
|
||||||
ai: AIConfig = field(default_factory=AIConfig)
|
|
||||||
|
|
||||||
# Simulation control
|
# Simulation control
|
||||||
auto_step_interval: float = 1.0 # Seconds between auto steps
|
auto_step_interval: float = 1.0 # Seconds between auto steps
|
||||||
@ -168,7 +144,6 @@ class SimulationConfig:
|
|||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary."""
|
"""Convert to dictionary."""
|
||||||
return {
|
return {
|
||||||
"ai": asdict(self.ai),
|
|
||||||
"agent_stats": asdict(self.agent_stats),
|
"agent_stats": asdict(self.agent_stats),
|
||||||
"resources": asdict(self.resources),
|
"resources": asdict(self.resources),
|
||||||
"actions": asdict(self.actions),
|
"actions": asdict(self.actions),
|
||||||
@ -182,7 +157,6 @@ class SimulationConfig:
|
|||||||
def from_dict(cls, data: dict) -> "SimulationConfig":
|
def from_dict(cls, data: dict) -> "SimulationConfig":
|
||||||
"""Create from dictionary."""
|
"""Create from dictionary."""
|
||||||
return cls(
|
return cls(
|
||||||
ai=AIConfig(**data.get("ai", {})),
|
|
||||||
agent_stats=AgentStatsConfig(**data.get("agent_stats", {})),
|
agent_stats=AgentStatsConfig(**data.get("agent_stats", {})),
|
||||||
resources=ResourceConfig(**data.get("resources", {})),
|
resources=ResourceConfig(**data.get("resources", {})),
|
||||||
actions=ActionConfig(**data.get("actions", {})),
|
actions=ActionConfig(**data.get("actions", {})),
|
||||||
|
|||||||
@ -1,21 +1,16 @@
|
|||||||
"""AI decision system for agents in the Village Simulation.
|
"""AI decision system for agents in the Village Simulation.
|
||||||
|
|
||||||
This module provides two AI systems:
|
Major rework to create diverse, personality-driven economy:
|
||||||
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
|
- Each agent has unique personality traits affecting all decisions
|
||||||
- Emergent professions: Hunters, Gatherers, Traders, Generalists
|
- Emergent professions: Hunters, Gatherers, Traders, Generalists
|
||||||
- Class inequality through varied strategies and skills
|
- Class inequality through varied strategies and skills
|
||||||
- Traders focus on arbitrage (buy low, sell high)
|
- Traders focus on arbitrage (buy low, sell high)
|
||||||
- Personality affects: risk tolerance, hoarding, market participation
|
- 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
|
import random
|
||||||
@ -151,8 +146,8 @@ class AgentAI:
|
|||||||
# Heat thresholds
|
# Heat thresholds
|
||||||
HEAT_PROACTIVE_THRESHOLD = 0.50
|
HEAT_PROACTIVE_THRESHOLD = 0.50
|
||||||
|
|
||||||
# Base economy settings (loaded from config, modified by personality)
|
# Base economy settings (modified by personality)
|
||||||
# These are default fallbacks; actual values come from config
|
ENERGY_TO_MONEY_RATIO = 1.5 # 1 energy = ~1.5 coins in perceived value
|
||||||
MAX_PRICE_MARKUP = 2.0 # Maximum markup over base price
|
MAX_PRICE_MARKUP = 2.0 # Maximum markup over base price
|
||||||
MIN_PRICE_DISCOUNT = 0.5 # Minimum discount (50% of base price)
|
MIN_PRICE_DISCOUNT = 0.5 # Minimum discount (50% of base price)
|
||||||
|
|
||||||
@ -176,22 +171,14 @@ class AgentAI:
|
|||||||
# Wealth desire from personality (0.1 to 0.9)
|
# Wealth desire from personality (0.1 to 0.9)
|
||||||
self.WEALTH_DESIRE = self.p.wealth_desire
|
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
|
# Buy efficiency threshold adjusted by price sensitivity
|
||||||
# High sensitivity = only buy very good deals
|
# 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
|
base_threshold = getattr(economy, 'buy_efficiency_threshold', 0.7) if economy else 0.7
|
||||||
self.BUY_EFFICIENCY_THRESHOLD = base_threshold / self.p.price_sensitivity
|
self.BUY_EFFICIENCY_THRESHOLD = base_threshold / self.p.price_sensitivity
|
||||||
|
|
||||||
# Wealth target scaled by wealth desire
|
# Wealth target scaled by wealth desire
|
||||||
base_target = getattr(economy, 'min_wealth_target', 5000) if economy else 5000
|
base_target = getattr(economy, 'min_wealth_target', 50) if economy else 50
|
||||||
self.MIN_WEALTH_TARGET = int(base_target * (0.5 + self.p.wealth_desire))
|
self.MIN_WEALTH_TARGET = int(base_target * (0.5 + self.p.wealth_desire))
|
||||||
|
|
||||||
# Resource stockpile targets modified by hoarding rate
|
# Resource stockpile targets modified by hoarding rate
|
||||||
@ -221,7 +208,7 @@ class AgentAI:
|
|||||||
and the maximum they should pay before just gathering themselves.
|
and the maximum they should pay before just gathering themselves.
|
||||||
"""
|
"""
|
||||||
energy_cost = get_energy_cost(resource_type)
|
energy_cost = get_energy_cost(resource_type)
|
||||||
return max(self.MIN_PRICE, int(energy_cost * self.ENERGY_TO_MONEY_RATIO))
|
return max(1, int(energy_cost * self.ENERGY_TO_MONEY_RATIO))
|
||||||
|
|
||||||
def _is_good_buy(self, resource_type: ResourceType, price: int) -> bool:
|
def _is_good_buy(self, resource_type: ResourceType, price: int) -> bool:
|
||||||
"""Check if a market price is a good deal (cheaper than gathering)."""
|
"""Check if a market price is a good deal (cheaper than gathering)."""
|
||||||
@ -917,7 +904,7 @@ class AgentAI:
|
|||||||
# Find cheapest competitor
|
# Find cheapest competitor
|
||||||
cheapest = self.market.get_cheapest_order(resource.type)
|
cheapest = self.market.get_cheapest_order(resource.type)
|
||||||
if cheapest and cheapest.seller_id != self.agent.id:
|
if cheapest and cheapest.seller_id != self.agent.id:
|
||||||
price = max(self.MIN_PRICE, cheapest.price_per_unit - 1)
|
price = max(1, cheapest.price_per_unit - 1)
|
||||||
else:
|
else:
|
||||||
price = int(fair_value * 0.8 * sell_modifier)
|
price = int(fair_value * 0.8 * sell_modifier)
|
||||||
score = 0.5 # Not a great time to sell
|
score = 0.5 # Not a great time to sell
|
||||||
@ -985,7 +972,7 @@ class AgentAI:
|
|||||||
# Match or undercut
|
# Match or undercut
|
||||||
suggested = min(suggested, cheapest.price_per_unit)
|
suggested = min(suggested, cheapest.price_per_unit)
|
||||||
|
|
||||||
return max(self.MIN_PRICE, suggested)
|
return max(1, suggested)
|
||||||
|
|
||||||
def _do_survival_work(self) -> AIDecision:
|
def _do_survival_work(self) -> AIDecision:
|
||||||
"""Perform work based on survival needs AND personality preferences.
|
"""Perform work based on survival needs AND personality preferences.
|
||||||
@ -1195,58 +1182,7 @@ class AgentAI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_ai_decision(
|
def get_ai_decision(agent: Agent, market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0) -> AIDecision:
|
||||||
agent: Agent,
|
"""Convenience function to get an AI decision for an 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)
|
ai = AgentAI(agent, market, step_in_day, day_steps, current_turn)
|
||||||
return ai.decide()
|
return ai.decide()
|
||||||
|
|||||||
@ -171,18 +171,20 @@ class GameEngine:
|
|||||||
money=agent.money,
|
money=agent.money,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get AI config to determine which system to use
|
if self.world.is_night():
|
||||||
ai_config = get_config().ai
|
# Force sleep at night
|
||||||
|
decision = AIDecision(
|
||||||
# GOAP AI handles night time automatically
|
action=ActionType.SLEEP,
|
||||||
|
reason="Night time: sleeping",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Pass time info so AI can prepare for night
|
||||||
decision = get_ai_decision(
|
decision = get_ai_decision(
|
||||||
agent,
|
agent,
|
||||||
self.market,
|
self.market,
|
||||||
step_in_day=self.world.step_in_day,
|
step_in_day=self.world.step_in_day,
|
||||||
day_steps=self.world.config.day_steps,
|
day_steps=self.world.config.day_steps,
|
||||||
current_turn=current_turn,
|
current_turn=current_turn,
|
||||||
use_goap=ai_config.use_goap,
|
|
||||||
is_night=self.world.is_night(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
decisions.append((agent, decision))
|
decisions.append((agent, decision))
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
"""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',
|
|
||||||
]
|
|
||||||
|
|
||||||
@ -1,419 +0,0 @@
|
|||||||
"""GOAP Action definitions.
|
|
||||||
|
|
||||||
Actions are the building blocks of plans. Each action has:
|
|
||||||
- Preconditions: What must be true for the action to be valid
|
|
||||||
- Effects: How the action changes the world state
|
|
||||||
- Cost: How expensive the action is (for planning)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Callable, Optional, TYPE_CHECKING
|
|
||||||
|
|
||||||
from backend.domain.action import ActionType
|
|
||||||
from backend.domain.resources import ResourceType
|
|
||||||
|
|
||||||
from .world_state import WorldState
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.domain.agent import Agent
|
|
||||||
from backend.core.market import OrderBook
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GOAPAction:
|
|
||||||
"""A GOAP action that can be part of a plan.
|
|
||||||
|
|
||||||
Actions transform the world state. The planner uses preconditions
|
|
||||||
and effects to search for valid action sequences.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
name: Human-readable name
|
|
||||||
action_type: The underlying ActionType to execute
|
|
||||||
target_resource: Optional resource this action targets
|
|
||||||
preconditions: Function that checks if action is valid in a state
|
|
||||||
effects: Function that returns the expected effects on state
|
|
||||||
cost: Function that calculates action cost (lower = preferred)
|
|
||||||
get_decision_params: Function to get parameters for AIDecision
|
|
||||||
"""
|
|
||||||
name: str
|
|
||||||
action_type: ActionType
|
|
||||||
target_resource: Optional[ResourceType] = None
|
|
||||||
|
|
||||||
# Functions that evaluate in context of world state
|
|
||||||
preconditions: Callable[[WorldState], bool] = field(default=lambda s: True)
|
|
||||||
effects: Callable[[WorldState], dict] = field(default=lambda s: {})
|
|
||||||
cost: Callable[[WorldState], float] = field(default=lambda s: 1.0)
|
|
||||||
|
|
||||||
# For generating the actual decision
|
|
||||||
get_decision_params: Optional[Callable[[WorldState, "Agent", "OrderBook"], dict]] = None
|
|
||||||
|
|
||||||
def is_valid(self, state: WorldState) -> bool:
|
|
||||||
"""Check if this action can be performed in the given state."""
|
|
||||||
return self.preconditions(state)
|
|
||||||
|
|
||||||
def apply(self, state: WorldState) -> WorldState:
|
|
||||||
"""Apply this action's effects to a state, returning a new state.
|
|
||||||
|
|
||||||
This is used by the planner for forward search.
|
|
||||||
"""
|
|
||||||
new_state = state.copy()
|
|
||||||
effects = self.effects(state)
|
|
||||||
|
|
||||||
for key, value in effects.items():
|
|
||||||
if hasattr(new_state, key):
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
# For numeric values, handle both absolute and relative changes
|
|
||||||
current = getattr(new_state, key)
|
|
||||||
if isinstance(current, bool):
|
|
||||||
setattr(new_state, key, bool(value))
|
|
||||||
else:
|
|
||||||
setattr(new_state, key, value)
|
|
||||||
else:
|
|
||||||
setattr(new_state, key, value)
|
|
||||||
|
|
||||||
# Recalculate urgencies
|
|
||||||
new_state._calculate_urgencies()
|
|
||||||
|
|
||||||
return new_state
|
|
||||||
|
|
||||||
def get_cost(self, state: WorldState) -> float:
|
|
||||||
"""Get the cost of this action in the given state."""
|
|
||||||
return self.cost(state)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
resource = f"({self.target_resource.value})" if self.target_resource else ""
|
|
||||||
return f"GOAPAction({self.name}{resource})"
|
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
|
||||||
return hash((self.name, self.action_type, self.target_resource))
|
|
||||||
|
|
||||||
def __eq__(self, other) -> bool:
|
|
||||||
if not isinstance(other, GOAPAction):
|
|
||||||
return False
|
|
||||||
return (self.name == other.name and
|
|
||||||
self.action_type == other.action_type and
|
|
||||||
self.target_resource == other.target_resource)
|
|
||||||
|
|
||||||
|
|
||||||
def create_consume_action(
|
|
||||||
resource_type: ResourceType,
|
|
||||||
stat_name: str,
|
|
||||||
stat_increase: float,
|
|
||||||
secondary_stat: Optional[str] = None,
|
|
||||||
secondary_increase: float = 0.0,
|
|
||||||
) -> GOAPAction:
|
|
||||||
"""Factory for creating consume resource actions."""
|
|
||||||
count_name = f"{resource_type.value}_count" if resource_type != ResourceType.BERRIES else "berries_count"
|
|
||||||
if resource_type == ResourceType.MEAT:
|
|
||||||
count_name = "meat_count"
|
|
||||||
elif resource_type == ResourceType.WATER:
|
|
||||||
count_name = "water_count"
|
|
||||||
|
|
||||||
# Map stat name to pct name
|
|
||||||
pct_name = f"{stat_name}_pct"
|
|
||||||
secondary_pct = f"{secondary_stat}_pct" if secondary_stat else None
|
|
||||||
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
count = getattr(state, count_name, 0)
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
result = {}
|
|
||||||
current = getattr(state, pct_name)
|
|
||||||
result[pct_name] = min(1.0, current + stat_increase)
|
|
||||||
|
|
||||||
if secondary_pct:
|
|
||||||
current_sec = getattr(state, secondary_pct)
|
|
||||||
result[secondary_pct] = min(1.0, current_sec + secondary_increase)
|
|
||||||
|
|
||||||
# Reduce resource count
|
|
||||||
current_count = getattr(state, count_name)
|
|
||||||
result[count_name] = max(0, current_count - 1)
|
|
||||||
|
|
||||||
# Update food count if consuming food
|
|
||||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
|
||||||
result["food_count"] = max(0, state.food_count - 1)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
# Consuming is very cheap - 0 energy cost
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name=f"Consume {resource_type.value}",
|
|
||||||
action_type=ActionType.CONSUME,
|
|
||||||
target_resource=resource_type,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_gather_action(
|
|
||||||
action_type: ActionType,
|
|
||||||
resource_type: ResourceType,
|
|
||||||
energy_cost: float,
|
|
||||||
expected_output: int,
|
|
||||||
success_chance: float = 1.0,
|
|
||||||
) -> GOAPAction:
|
|
||||||
"""Factory for creating resource gathering actions."""
|
|
||||||
count_name = f"{resource_type.value}_count"
|
|
||||||
if resource_type == ResourceType.BERRIES:
|
|
||||||
count_name = "berries_count"
|
|
||||||
elif resource_type == ResourceType.MEAT:
|
|
||||||
count_name = "meat_count"
|
|
||||||
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
# Need enough energy and inventory space
|
|
||||||
energy_needed = abs(energy_cost) / 50.0 # Convert to percentage
|
|
||||||
return state.energy_pct >= energy_needed + 0.05 and state.inventory_space > 0
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
# Spend energy
|
|
||||||
energy_spent = abs(energy_cost) / 50.0
|
|
||||||
result["energy_pct"] = max(0, state.energy_pct - energy_spent)
|
|
||||||
|
|
||||||
# Gain resources (adjusted for success chance)
|
|
||||||
effective_output = int(expected_output * success_chance)
|
|
||||||
current = getattr(state, count_name)
|
|
||||||
result[count_name] = current + effective_output
|
|
||||||
|
|
||||||
# Update food count if gathering food
|
|
||||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
|
||||||
result["food_count"] = state.food_count + effective_output
|
|
||||||
|
|
||||||
# Update inventory space
|
|
||||||
result["inventory_space"] = max(0, state.inventory_space - effective_output)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
# Calculate cost based on efficiency (energy per unit of food)
|
|
||||||
food_per_action = expected_output * success_chance
|
|
||||||
if food_per_action > 0:
|
|
||||||
base_cost = abs(energy_cost) / food_per_action * 0.5
|
|
||||||
else:
|
|
||||||
base_cost = abs(energy_cost) / 5.0
|
|
||||||
|
|
||||||
# Adjust for success chance (penalize unreliable actions slightly)
|
|
||||||
if success_chance < 1.0:
|
|
||||||
base_cost *= 1.0 + (1.0 - success_chance) * 0.3
|
|
||||||
|
|
||||||
# STRONG profession specialization effect for gathering
|
|
||||||
if action_type == ActionType.GATHER:
|
|
||||||
# Compare gather_preference to other preferences
|
|
||||||
# Specialists get big discounts, generalists pay penalty
|
|
||||||
other_prefs = (state.hunt_preference + state.trade_preference) / 2
|
|
||||||
relative_strength = state.gather_preference / max(0.1, other_prefs)
|
|
||||||
|
|
||||||
# relative_strength > 1.0 means gathering is your specialty
|
|
||||||
# relative_strength < 1.0 means you're NOT a gatherer
|
|
||||||
if relative_strength >= 1.0:
|
|
||||||
# Specialist discount: up to 50% off
|
|
||||||
preference_modifier = 1.0 / relative_strength
|
|
||||||
else:
|
|
||||||
# Non-specialist penalty: up to 3x cost
|
|
||||||
preference_modifier = 1.0 + (1.0 - relative_strength) * 2.0
|
|
||||||
|
|
||||||
base_cost *= preference_modifier
|
|
||||||
|
|
||||||
# Skill reduces cost further (experienced = efficient)
|
|
||||||
# skill 0: no bonus, skill 1.0: 40% discount
|
|
||||||
skill_modifier = 1.0 - state.gathering_skill * 0.4
|
|
||||||
base_cost *= skill_modifier
|
|
||||||
|
|
||||||
return base_cost
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name=f"{action_type.value}",
|
|
||||||
action_type=action_type,
|
|
||||||
target_resource=resource_type,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_buy_action(resource_type: ResourceType) -> GOAPAction:
|
|
||||||
"""Factory for creating market buy actions."""
|
|
||||||
can_buy_name = f"can_buy_{resource_type.value}"
|
|
||||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
|
||||||
can_buy_name = "can_buy_food" # Simplified - we check specific later
|
|
||||||
|
|
||||||
count_name = f"{resource_type.value}_count"
|
|
||||||
if resource_type == ResourceType.BERRIES:
|
|
||||||
count_name = "berries_count"
|
|
||||||
elif resource_type == ResourceType.MEAT:
|
|
||||||
count_name = "meat_count"
|
|
||||||
|
|
||||||
price_name = f"{resource_type.value}_market_price"
|
|
||||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
|
||||||
price_name = "food_market_price"
|
|
||||||
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
# Check specific availability
|
|
||||||
if resource_type == ResourceType.MEAT:
|
|
||||||
can_buy = state.can_buy_meat
|
|
||||||
elif resource_type == ResourceType.BERRIES:
|
|
||||||
can_buy = state.can_buy_berries
|
|
||||||
else:
|
|
||||||
can_buy = getattr(state, f"can_buy_{resource_type.value}", False)
|
|
||||||
|
|
||||||
return can_buy and state.inventory_space > 0
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
# Get price
|
|
||||||
if resource_type == ResourceType.MEAT:
|
|
||||||
price = state.food_market_price
|
|
||||||
elif resource_type == ResourceType.BERRIES:
|
|
||||||
price = state.food_market_price
|
|
||||||
else:
|
|
||||||
price = getattr(state, price_name, 10)
|
|
||||||
|
|
||||||
# Spend money
|
|
||||||
result["money"] = state.money - price
|
|
||||||
|
|
||||||
# Gain resource
|
|
||||||
current = getattr(state, count_name)
|
|
||||||
result[count_name] = current + 1
|
|
||||||
|
|
||||||
# Update food count if buying food
|
|
||||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
|
||||||
result["food_count"] = state.food_count + 1
|
|
||||||
|
|
||||||
# Spend small energy
|
|
||||||
result["energy_pct"] = max(0, state.energy_pct - 0.02)
|
|
||||||
|
|
||||||
# Update inventory
|
|
||||||
result["inventory_space"] = max(0, state.inventory_space - 1)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
# Trading cost is low (1 energy)
|
|
||||||
base_cost = 0.5
|
|
||||||
|
|
||||||
# MILD profession effect for trading (everyone should be able to trade)
|
|
||||||
# Traders get a bonus, but non-traders shouldn't be heavily penalized
|
|
||||||
# (trading benefits the whole economy)
|
|
||||||
other_prefs = (state.hunt_preference + state.gather_preference) / 2
|
|
||||||
relative_strength = state.trade_preference / max(0.1, other_prefs)
|
|
||||||
|
|
||||||
if relative_strength >= 1.0:
|
|
||||||
# Specialist discount: up to 40% off for dedicated traders
|
|
||||||
preference_modifier = max(0.6, 1.0 / relative_strength)
|
|
||||||
else:
|
|
||||||
# Mild non-specialist penalty: up to 50% cost increase
|
|
||||||
preference_modifier = 1.0 + (1.0 - relative_strength) * 0.5
|
|
||||||
|
|
||||||
base_cost *= preference_modifier
|
|
||||||
|
|
||||||
# Skill reduces cost (experienced traders are efficient)
|
|
||||||
# skill 0: no bonus, skill 1.0: 40% discount
|
|
||||||
skill_modifier = 1.0 - state.trading_skill * 0.4
|
|
||||||
base_cost *= skill_modifier
|
|
||||||
|
|
||||||
# Market affinity still has mild effect
|
|
||||||
base_cost *= (1.2 - state.market_affinity * 0.4)
|
|
||||||
|
|
||||||
# Check if it's a good deal
|
|
||||||
if resource_type == ResourceType.MEAT:
|
|
||||||
price = state.food_market_price
|
|
||||||
elif resource_type == ResourceType.BERRIES:
|
|
||||||
price = state.food_market_price
|
|
||||||
else:
|
|
||||||
price = getattr(state, price_name, 100)
|
|
||||||
|
|
||||||
# Higher price = higher cost (scaled for 100-500g price range)
|
|
||||||
# At fair value (~150g), multiplier is ~1.5x
|
|
||||||
# At min price (100g), multiplier is ~1.33x
|
|
||||||
base_cost *= (1.0 + price / 300.0)
|
|
||||||
|
|
||||||
return base_cost
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name=f"Buy {resource_type.value}",
|
|
||||||
action_type=ActionType.TRADE,
|
|
||||||
target_resource=resource_type,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_rest_action() -> GOAPAction:
|
|
||||||
"""Create the rest action."""
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
return state.energy_pct < 0.9 # Only rest if not full
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
# Rest restores energy (12 out of 50 = 0.24)
|
|
||||||
return {
|
|
||||||
"energy_pct": min(1.0, state.energy_pct + 0.24),
|
|
||||||
}
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
# Resting is cheap but we prefer productive actions
|
|
||||||
return 2.0
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name="Rest",
|
|
||||||
action_type=ActionType.REST,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_build_fire_action() -> GOAPAction:
|
|
||||||
"""Create the build fire action."""
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
return state.wood_count > 0 and state.energy_pct >= 0.1
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
return {
|
|
||||||
"heat_pct": min(1.0, state.heat_pct + 0.20), # Fire gives 20 heat out of 100
|
|
||||||
"wood_count": max(0, state.wood_count - 1),
|
|
||||||
"energy_pct": max(0, state.energy_pct - 0.08), # 4 energy cost
|
|
||||||
}
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
# Building fire is relatively cheap when we have wood
|
|
||||||
return 1.5
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name="Build Fire",
|
|
||||||
action_type=ActionType.BUILD_FIRE,
|
|
||||||
target_resource=ResourceType.WOOD,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_sleep_action() -> GOAPAction:
|
|
||||||
"""Create the sleep action (for night)."""
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
return state.is_night
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
return {
|
|
||||||
"energy_pct": 1.0, # Full energy restore
|
|
||||||
}
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
return 0.0 # Sleep is mandatory at night
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name="Sleep",
|
|
||||||
action_type=ActionType.SLEEP,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
@ -1,399 +0,0 @@
|
|||||||
"""Predefined GOAP actions for agents.
|
|
||||||
|
|
||||||
Actions are organized by category:
|
|
||||||
- Consume actions: Use resources from inventory
|
|
||||||
- Gather actions: Produce resources
|
|
||||||
- Trade actions: Buy/sell on market
|
|
||||||
- Utility actions: Rest, sleep, build fire
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional, TYPE_CHECKING
|
|
||||||
|
|
||||||
from backend.domain.action import ActionType, ACTION_CONFIG
|
|
||||||
from backend.domain.resources import ResourceType
|
|
||||||
|
|
||||||
from .world_state import WorldState
|
|
||||||
from .action import (
|
|
||||||
GOAPAction,
|
|
||||||
create_consume_action,
|
|
||||||
create_gather_action,
|
|
||||||
create_buy_action,
|
|
||||||
create_rest_action,
|
|
||||||
create_build_fire_action,
|
|
||||||
create_sleep_action,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.domain.agent import Agent
|
|
||||||
from backend.core.market import OrderBook
|
|
||||||
|
|
||||||
|
|
||||||
def _get_action_configs():
|
|
||||||
"""Get action configurations from config."""
|
|
||||||
return ACTION_CONFIG
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CONSUME ACTIONS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _create_drink_water() -> GOAPAction:
|
|
||||||
"""Drink water to restore thirst."""
|
|
||||||
return create_consume_action(
|
|
||||||
resource_type=ResourceType.WATER,
|
|
||||||
stat_name="thirst",
|
|
||||||
stat_increase=0.50, # 50 thirst out of 100
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_eat_meat() -> GOAPAction:
|
|
||||||
"""Eat meat to restore hunger (primary food source)."""
|
|
||||||
return create_consume_action(
|
|
||||||
resource_type=ResourceType.MEAT,
|
|
||||||
stat_name="hunger",
|
|
||||||
stat_increase=0.35, # 35 hunger
|
|
||||||
secondary_stat="energy",
|
|
||||||
secondary_increase=0.24, # 12 energy
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_eat_berries() -> GOAPAction:
|
|
||||||
"""Eat berries to restore hunger and some thirst."""
|
|
||||||
return create_consume_action(
|
|
||||||
resource_type=ResourceType.BERRIES,
|
|
||||||
stat_name="hunger",
|
|
||||||
stat_increase=0.10, # 10 hunger
|
|
||||||
secondary_stat="thirst",
|
|
||||||
secondary_increase=0.04, # 4 thirst
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CONSUME_ACTIONS = [
|
|
||||||
_create_drink_water(),
|
|
||||||
_create_eat_meat(),
|
|
||||||
_create_eat_berries(),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# GATHER ACTIONS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _create_get_water() -> GOAPAction:
|
|
||||||
"""Get water from the river."""
|
|
||||||
config = _get_action_configs()[ActionType.GET_WATER]
|
|
||||||
return create_gather_action(
|
|
||||||
action_type=ActionType.GET_WATER,
|
|
||||||
resource_type=ResourceType.WATER,
|
|
||||||
energy_cost=config.energy_cost,
|
|
||||||
expected_output=1,
|
|
||||||
success_chance=1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_gather_berries() -> GOAPAction:
|
|
||||||
"""Gather berries (safe, reliable)."""
|
|
||||||
config = _get_action_configs()[ActionType.GATHER]
|
|
||||||
expected = (config.min_output + config.max_output) // 2
|
|
||||||
return create_gather_action(
|
|
||||||
action_type=ActionType.GATHER,
|
|
||||||
resource_type=ResourceType.BERRIES,
|
|
||||||
energy_cost=config.energy_cost,
|
|
||||||
expected_output=expected,
|
|
||||||
success_chance=1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_hunt() -> GOAPAction:
|
|
||||||
"""Hunt for meat (risky, high reward).
|
|
||||||
|
|
||||||
Hunt should be attractive because:
|
|
||||||
- Meat gives much more hunger than berries (35 vs 10)
|
|
||||||
- Meat also gives energy (12)
|
|
||||||
- You also get hide for clothes
|
|
||||||
|
|
||||||
Cost is balanced against gathering:
|
|
||||||
- Hunt: -7 energy, 70% success, 2-5 meat + 0-2 hide
|
|
||||||
- Gather: -3 energy, 100% success, 2-4 berries
|
|
||||||
|
|
||||||
Effective food per energy:
|
|
||||||
- Hunt: 3.5 meat avg * 0.7 = 2.45 meat = 2.45 * 35 hunger = 85.75 hunger for 7 energy = 12.25 hunger/energy
|
|
||||||
- Gather: 3 berries avg * 1.0 = 3 berries = 3 * 10 hunger = 30 hunger for 3 energy = 10 hunger/energy
|
|
||||||
|
|
||||||
So hunting is actually MORE efficient per energy for hunger! The cost should reflect this.
|
|
||||||
"""
|
|
||||||
config = _get_action_configs()[ActionType.HUNT]
|
|
||||||
expected = (config.min_output + config.max_output) // 2
|
|
||||||
|
|
||||||
# Custom preconditions for hunting
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
# Need more energy for hunting (but not excessively so)
|
|
||||||
energy_needed = abs(config.energy_cost) / 50.0 + 0.05
|
|
||||||
return state.energy_pct >= energy_needed and state.inventory_space >= 2
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
# Account for success chance
|
|
||||||
effective_meat = int(expected * config.success_chance)
|
|
||||||
effective_hide = int(1 * config.success_chance) # Average hide
|
|
||||||
|
|
||||||
energy_spent = abs(config.energy_cost) / 50.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"energy_pct": max(0, state.energy_pct - energy_spent),
|
|
||||||
"meat_count": state.meat_count + effective_meat,
|
|
||||||
"food_count": state.food_count + effective_meat,
|
|
||||||
"hide_count": state.hide_count + effective_hide,
|
|
||||||
"inventory_space": max(0, state.inventory_space - effective_meat - effective_hide),
|
|
||||||
}
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
# Hunt should be comparable to gather when considering value:
|
|
||||||
# - Hunt gives 3.5 meat avg (35 hunger each) = 122.5 hunger value
|
|
||||||
# - Gather gives 3 berries avg (10 hunger each) = 30 hunger value
|
|
||||||
# Hunt is 4x more valuable for hunger! So cost can be higher but not 4x.
|
|
||||||
|
|
||||||
# Base cost similar to gather
|
|
||||||
base_cost = 0.6
|
|
||||||
|
|
||||||
# Success chance penalty (small)
|
|
||||||
if config.success_chance < 1.0:
|
|
||||||
base_cost *= 1.0 + (1.0 - config.success_chance) * 0.2
|
|
||||||
|
|
||||||
# STRONG profession specialization effect for hunting
|
|
||||||
# Compare hunt_preference to other preferences
|
|
||||||
other_prefs = (state.gather_preference + state.trade_preference) / 2
|
|
||||||
relative_strength = state.hunt_preference / max(0.1, other_prefs)
|
|
||||||
|
|
||||||
# relative_strength > 1.0 means hunting is your specialty
|
|
||||||
if relative_strength >= 1.0:
|
|
||||||
# Specialist discount: up to 50% off
|
|
||||||
preference_modifier = 1.0 / relative_strength
|
|
||||||
else:
|
|
||||||
# Non-specialist penalty: up to 3x cost
|
|
||||||
preference_modifier = 1.0 + (1.0 - relative_strength) * 2.0
|
|
||||||
|
|
||||||
base_cost *= preference_modifier
|
|
||||||
|
|
||||||
# Skill reduces cost further (experienced hunters are efficient)
|
|
||||||
# skill 0: no bonus, skill 1.0: 40% discount
|
|
||||||
skill_modifier = 1.0 - state.hunting_skill * 0.4
|
|
||||||
base_cost *= skill_modifier
|
|
||||||
|
|
||||||
# Risk tolerance still has mild effect
|
|
||||||
risk_modifier = 1.0 + (0.5 - state.risk_tolerance) * 0.15
|
|
||||||
base_cost *= risk_modifier
|
|
||||||
|
|
||||||
# Big bonus if we have no meat - prioritize getting some
|
|
||||||
if state.meat_count == 0:
|
|
||||||
base_cost *= 0.6
|
|
||||||
|
|
||||||
# Bonus if low on food in general
|
|
||||||
if state.food_count < 2:
|
|
||||||
base_cost *= 0.8
|
|
||||||
|
|
||||||
return base_cost
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name="Hunt",
|
|
||||||
action_type=ActionType.HUNT,
|
|
||||||
target_resource=ResourceType.MEAT,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_chop_wood() -> GOAPAction:
|
|
||||||
"""Chop wood for fires."""
|
|
||||||
config = _get_action_configs()[ActionType.CHOP_WOOD]
|
|
||||||
expected = (config.min_output + config.max_output) // 2
|
|
||||||
return create_gather_action(
|
|
||||||
action_type=ActionType.CHOP_WOOD,
|
|
||||||
resource_type=ResourceType.WOOD,
|
|
||||||
energy_cost=config.energy_cost,
|
|
||||||
expected_output=expected,
|
|
||||||
success_chance=config.success_chance,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_weave_clothes() -> GOAPAction:
|
|
||||||
"""Craft clothes from hide."""
|
|
||||||
config = _get_action_configs()[ActionType.WEAVE]
|
|
||||||
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
return (
|
|
||||||
state.hide_count >= 1 and
|
|
||||||
not state.has_clothes and
|
|
||||||
state.energy_pct >= abs(config.energy_cost) / 50.0 + 0.05
|
|
||||||
)
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
return {
|
|
||||||
"has_clothes": True,
|
|
||||||
"hide_count": state.hide_count - 1,
|
|
||||||
"energy_pct": max(0, state.energy_pct - abs(config.energy_cost) / 50.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
return abs(config.energy_cost) / 3.0
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name="Weave Clothes",
|
|
||||||
action_type=ActionType.WEAVE,
|
|
||||||
target_resource=ResourceType.CLOTHES,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
GATHER_ACTIONS = [
|
|
||||||
_create_get_water(),
|
|
||||||
_create_gather_berries(),
|
|
||||||
_create_hunt(),
|
|
||||||
_create_chop_wood(),
|
|
||||||
_create_weave_clothes(),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# TRADE ACTIONS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _create_buy_water() -> GOAPAction:
|
|
||||||
"""Buy water from the market."""
|
|
||||||
return create_buy_action(ResourceType.WATER)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_buy_meat() -> GOAPAction:
|
|
||||||
"""Buy meat from the market."""
|
|
||||||
return create_buy_action(ResourceType.MEAT)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_buy_berries() -> GOAPAction:
|
|
||||||
"""Buy berries from the market."""
|
|
||||||
return create_buy_action(ResourceType.BERRIES)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_buy_wood() -> GOAPAction:
|
|
||||||
"""Buy wood from the market."""
|
|
||||||
return create_buy_action(ResourceType.WOOD)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_sell_action(resource_type: ResourceType, min_keep: int = 1) -> GOAPAction:
|
|
||||||
"""Factory for creating sell actions."""
|
|
||||||
count_name = f"{resource_type.value}_count"
|
|
||||||
if resource_type == ResourceType.BERRIES:
|
|
||||||
count_name = "berries_count"
|
|
||||||
elif resource_type == ResourceType.MEAT:
|
|
||||||
count_name = "meat_count"
|
|
||||||
|
|
||||||
def preconditions(state: WorldState) -> bool:
|
|
||||||
current = getattr(state, count_name)
|
|
||||||
return current > min_keep and state.energy_pct >= 0.05
|
|
||||||
|
|
||||||
def effects(state: WorldState) -> dict:
|
|
||||||
# Estimate we'll get a reasonable price (around min_price from config)
|
|
||||||
# This is approximate - actual execution will get real prices
|
|
||||||
estimated_price = 100 # Base estimate (min_price from config)
|
|
||||||
|
|
||||||
current = getattr(state, count_name)
|
|
||||||
sell_qty = min(3, current - min_keep) # Sell up to 3, keep minimum
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"money": state.money + estimated_price * sell_qty,
|
|
||||||
count_name: current - sell_qty,
|
|
||||||
"inventory_space": state.inventory_space + sell_qty,
|
|
||||||
"energy_pct": max(0, state.energy_pct - 0.02),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update food count if selling food
|
|
||||||
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
|
|
||||||
result["food_count"] = state.food_count - sell_qty
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def cost(state: WorldState) -> float:
|
|
||||||
# Selling has low cost - everyone should be able to sell excess
|
|
||||||
base_cost = 1.0
|
|
||||||
|
|
||||||
# MILD profession effect for selling (everyone should be able to trade)
|
|
||||||
other_prefs = (state.hunt_preference + state.gather_preference) / 2
|
|
||||||
relative_strength = state.trade_preference / max(0.1, other_prefs)
|
|
||||||
|
|
||||||
if relative_strength >= 1.0:
|
|
||||||
# Specialist discount: up to 40% off for dedicated traders
|
|
||||||
preference_modifier = max(0.6, 1.0 / relative_strength)
|
|
||||||
else:
|
|
||||||
# Mild non-specialist penalty: up to 50% cost increase
|
|
||||||
preference_modifier = 1.0 + (1.0 - relative_strength) * 0.5
|
|
||||||
|
|
||||||
base_cost *= preference_modifier
|
|
||||||
|
|
||||||
# Skill reduces cost (experienced traders know the market)
|
|
||||||
# skill 0: no bonus, skill 1.0: 40% discount
|
|
||||||
skill_modifier = 1.0 - state.trading_skill * 0.4
|
|
||||||
base_cost *= skill_modifier
|
|
||||||
|
|
||||||
# Hoarders reluctant to sell (mild effect)
|
|
||||||
base_cost *= (0.8 + state.hoarding_rate * 0.4)
|
|
||||||
|
|
||||||
return base_cost
|
|
||||||
|
|
||||||
return GOAPAction(
|
|
||||||
name=f"Sell {resource_type.value}",
|
|
||||||
action_type=ActionType.TRADE,
|
|
||||||
target_resource=resource_type,
|
|
||||||
preconditions=preconditions,
|
|
||||||
effects=effects,
|
|
||||||
cost=cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
TRADE_ACTIONS = [
|
|
||||||
_create_buy_water(),
|
|
||||||
_create_buy_meat(),
|
|
||||||
_create_buy_berries(),
|
|
||||||
_create_buy_wood(),
|
|
||||||
_create_sell_action(ResourceType.WATER, min_keep=2),
|
|
||||||
_create_sell_action(ResourceType.MEAT, min_keep=1),
|
|
||||||
_create_sell_action(ResourceType.BERRIES, min_keep=2),
|
|
||||||
_create_sell_action(ResourceType.WOOD, min_keep=1),
|
|
||||||
_create_sell_action(ResourceType.HIDE, min_keep=0),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# UTILITY ACTIONS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
UTILITY_ACTIONS = [
|
|
||||||
create_rest_action(),
|
|
||||||
create_build_fire_action(),
|
|
||||||
create_sleep_action(),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# ALL ACTIONS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def get_all_actions() -> list[GOAPAction]:
|
|
||||||
"""Get all available GOAP actions."""
|
|
||||||
return CONSUME_ACTIONS + GATHER_ACTIONS + TRADE_ACTIONS + UTILITY_ACTIONS
|
|
||||||
|
|
||||||
|
|
||||||
def get_action_by_type(action_type: ActionType) -> list[GOAPAction]:
|
|
||||||
"""Get all GOAP actions of a specific type."""
|
|
||||||
all_actions = get_all_actions()
|
|
||||||
return [a for a in all_actions if a.action_type == action_type]
|
|
||||||
|
|
||||||
|
|
||||||
def get_action_by_name(name: str) -> Optional[GOAPAction]:
|
|
||||||
"""Get a specific action by name."""
|
|
||||||
all_actions = get_all_actions()
|
|
||||||
for action in all_actions:
|
|
||||||
if action.name == name:
|
|
||||||
return action
|
|
||||||
return None
|
|
||||||
|
|
||||||
@ -1,258 +0,0 @@
|
|||||||
"""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()
|
|
||||||
]
|
|
||||||
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@ -1,411 +0,0 @@
|
|||||||
"""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
|
|
||||||
|
|
||||||
@ -1,411 +0,0 @@
|
|||||||
"""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()
|
|
||||||
|
|
||||||
@ -1,335 +0,0 @@
|
|||||||
"""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)
|
|
||||||
|
|
||||||
@ -1,319 +0,0 @@
|
|||||||
"""World State representation for GOAP planning.
|
|
||||||
|
|
||||||
The WorldState is a symbolic representation of the agent's current situation,
|
|
||||||
used by the planner to reason about actions and goals.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.domain.agent import Agent
|
|
||||||
from backend.core.market import OrderBook
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WorldState:
|
|
||||||
"""Symbolic representation of the world from an agent's perspective.
|
|
||||||
|
|
||||||
This captures all relevant state needed for GOAP planning:
|
|
||||||
- Agent vital stats (as percentages 0-1)
|
|
||||||
- Resource counts in inventory
|
|
||||||
- Market availability
|
|
||||||
- Economic state
|
|
||||||
- Time of day
|
|
||||||
|
|
||||||
The state uses normalized values (0-1) for stats to make
|
|
||||||
threshold comparisons easy and consistent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Vital stats as percentages (0.0 to 1.0)
|
|
||||||
thirst_pct: float = 1.0
|
|
||||||
hunger_pct: float = 1.0
|
|
||||||
heat_pct: float = 1.0
|
|
||||||
energy_pct: float = 1.0
|
|
||||||
|
|
||||||
# Resource counts in inventory
|
|
||||||
water_count: int = 0
|
|
||||||
food_count: int = 0 # meat + berries
|
|
||||||
meat_count: int = 0
|
|
||||||
berries_count: int = 0
|
|
||||||
wood_count: int = 0
|
|
||||||
hide_count: int = 0
|
|
||||||
|
|
||||||
# Inventory state
|
|
||||||
has_clothes: bool = False
|
|
||||||
inventory_space: int = 0
|
|
||||||
inventory_full: bool = False
|
|
||||||
|
|
||||||
# Economic state
|
|
||||||
money: int = 0
|
|
||||||
is_wealthy: bool = False # Has comfortable money reserves
|
|
||||||
|
|
||||||
# Market availability (can we buy these?)
|
|
||||||
can_buy_water: bool = False
|
|
||||||
can_buy_food: bool = False
|
|
||||||
can_buy_meat: bool = False
|
|
||||||
can_buy_berries: bool = False
|
|
||||||
can_buy_wood: bool = False
|
|
||||||
water_market_price: int = 0
|
|
||||||
food_market_price: int = 0 # Cheapest of meat/berries
|
|
||||||
wood_market_price: int = 0
|
|
||||||
|
|
||||||
# Time state
|
|
||||||
is_night: bool = False
|
|
||||||
is_evening: bool = False # Near end of day
|
|
||||||
step_in_day: int = 0
|
|
||||||
day_steps: int = 10
|
|
||||||
|
|
||||||
# Agent personality shortcuts (affect goal priorities)
|
|
||||||
wealth_desire: float = 0.5
|
|
||||||
hoarding_rate: float = 0.5
|
|
||||||
risk_tolerance: float = 0.5
|
|
||||||
market_affinity: float = 0.5
|
|
||||||
is_trader: bool = False
|
|
||||||
|
|
||||||
# Profession preferences (0.5-1.5 range, higher = more preferred)
|
|
||||||
gather_preference: float = 1.0
|
|
||||||
hunt_preference: float = 1.0
|
|
||||||
trade_preference: float = 1.0
|
|
||||||
|
|
||||||
# Skill levels (0.0-1.0, higher = more skilled)
|
|
||||||
hunting_skill: float = 0.0
|
|
||||||
gathering_skill: float = 0.0
|
|
||||||
trading_skill: float = 0.0
|
|
||||||
|
|
||||||
# Critical thresholds (from config)
|
|
||||||
critical_threshold: float = 0.25
|
|
||||||
low_threshold: float = 0.45
|
|
||||||
|
|
||||||
# Calculated urgencies (how urgent is each need?)
|
|
||||||
thirst_urgency: float = 0.0
|
|
||||||
hunger_urgency: float = 0.0
|
|
||||||
heat_urgency: float = 0.0
|
|
||||||
energy_urgency: float = 0.0
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Calculate urgencies after initialization."""
|
|
||||||
self._calculate_urgencies()
|
|
||||||
|
|
||||||
def _calculate_urgencies(self):
|
|
||||||
"""Calculate urgency values for each vital stat.
|
|
||||||
|
|
||||||
Urgency is 0 when stat is full, and increases as stat decreases.
|
|
||||||
Urgency > 1.0 when in critical range.
|
|
||||||
"""
|
|
||||||
# Urgency increases as stat decreases
|
|
||||||
# 0.0 = no urgency, 1.0 = needs attention, 2.0+ = critical
|
|
||||||
|
|
||||||
def calc_urgency(pct: float, critical: float, low: float) -> float:
|
|
||||||
if pct >= low:
|
|
||||||
return 0.0
|
|
||||||
elif pct >= critical:
|
|
||||||
# Linear increase from 0 to 1 as we go from low to critical
|
|
||||||
return 1.0 - (pct - critical) / (low - critical)
|
|
||||||
else:
|
|
||||||
# Exponential increase below critical
|
|
||||||
return 1.0 + (critical - pct) / critical * 2.0
|
|
||||||
|
|
||||||
self.thirst_urgency = calc_urgency(self.thirst_pct, self.critical_threshold, self.low_threshold)
|
|
||||||
self.hunger_urgency = calc_urgency(self.hunger_pct, self.critical_threshold, self.low_threshold)
|
|
||||||
self.heat_urgency = calc_urgency(self.heat_pct, self.critical_threshold, self.low_threshold)
|
|
||||||
|
|
||||||
# Energy urgency is different - we care about absolute level for work
|
|
||||||
if self.energy_pct < 0.25:
|
|
||||||
self.energy_urgency = 2.0
|
|
||||||
elif self.energy_pct < 0.40:
|
|
||||||
self.energy_urgency = 1.0
|
|
||||||
else:
|
|
||||||
self.energy_urgency = 0.0
|
|
||||||
|
|
||||||
def copy(self) -> "WorldState":
|
|
||||||
"""Create a copy of this world state."""
|
|
||||||
return WorldState(
|
|
||||||
thirst_pct=self.thirst_pct,
|
|
||||||
hunger_pct=self.hunger_pct,
|
|
||||||
heat_pct=self.heat_pct,
|
|
||||||
energy_pct=self.energy_pct,
|
|
||||||
water_count=self.water_count,
|
|
||||||
food_count=self.food_count,
|
|
||||||
meat_count=self.meat_count,
|
|
||||||
berries_count=self.berries_count,
|
|
||||||
wood_count=self.wood_count,
|
|
||||||
hide_count=self.hide_count,
|
|
||||||
has_clothes=self.has_clothes,
|
|
||||||
inventory_space=self.inventory_space,
|
|
||||||
inventory_full=self.inventory_full,
|
|
||||||
money=self.money,
|
|
||||||
is_wealthy=self.is_wealthy,
|
|
||||||
can_buy_water=self.can_buy_water,
|
|
||||||
can_buy_food=self.can_buy_food,
|
|
||||||
can_buy_meat=self.can_buy_meat,
|
|
||||||
can_buy_berries=self.can_buy_berries,
|
|
||||||
can_buy_wood=self.can_buy_wood,
|
|
||||||
water_market_price=self.water_market_price,
|
|
||||||
food_market_price=self.food_market_price,
|
|
||||||
wood_market_price=self.wood_market_price,
|
|
||||||
is_night=self.is_night,
|
|
||||||
is_evening=self.is_evening,
|
|
||||||
step_in_day=self.step_in_day,
|
|
||||||
day_steps=self.day_steps,
|
|
||||||
wealth_desire=self.wealth_desire,
|
|
||||||
hoarding_rate=self.hoarding_rate,
|
|
||||||
risk_tolerance=self.risk_tolerance,
|
|
||||||
market_affinity=self.market_affinity,
|
|
||||||
is_trader=self.is_trader,
|
|
||||||
critical_threshold=self.critical_threshold,
|
|
||||||
low_threshold=self.low_threshold,
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
"""Convert to dictionary for debugging/logging."""
|
|
||||||
return {
|
|
||||||
"vitals": {
|
|
||||||
"thirst": round(self.thirst_pct, 2),
|
|
||||||
"hunger": round(self.hunger_pct, 2),
|
|
||||||
"heat": round(self.heat_pct, 2),
|
|
||||||
"energy": round(self.energy_pct, 2),
|
|
||||||
},
|
|
||||||
"urgencies": {
|
|
||||||
"thirst": round(self.thirst_urgency, 2),
|
|
||||||
"hunger": round(self.hunger_urgency, 2),
|
|
||||||
"heat": round(self.heat_urgency, 2),
|
|
||||||
"energy": round(self.energy_urgency, 2),
|
|
||||||
},
|
|
||||||
"inventory": {
|
|
||||||
"water": self.water_count,
|
|
||||||
"meat": self.meat_count,
|
|
||||||
"berries": self.berries_count,
|
|
||||||
"wood": self.wood_count,
|
|
||||||
"hide": self.hide_count,
|
|
||||||
"space": self.inventory_space,
|
|
||||||
},
|
|
||||||
"economy": {
|
|
||||||
"money": self.money,
|
|
||||||
"is_wealthy": self.is_wealthy,
|
|
||||||
},
|
|
||||||
"market": {
|
|
||||||
"can_buy_water": self.can_buy_water,
|
|
||||||
"can_buy_food": self.can_buy_food,
|
|
||||||
"can_buy_wood": self.can_buy_wood,
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"is_night": self.is_night,
|
|
||||||
"is_evening": self.is_evening,
|
|
||||||
"step": self.step_in_day,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_world_state(
|
|
||||||
agent: "Agent",
|
|
||||||
market: "OrderBook",
|
|
||||||
step_in_day: int = 1,
|
|
||||||
day_steps: int = 10,
|
|
||||||
is_night: bool = False,
|
|
||||||
) -> WorldState:
|
|
||||||
"""Create a WorldState from an agent and market.
|
|
||||||
|
|
||||||
This is the main factory function for creating world states.
|
|
||||||
It extracts all relevant information from the agent and market.
|
|
||||||
"""
|
|
||||||
from backend.domain.resources import ResourceType
|
|
||||||
from backend.config import get_config
|
|
||||||
|
|
||||||
config = get_config()
|
|
||||||
agent_config = config.agent_stats
|
|
||||||
economy_config = getattr(config, 'economy', None)
|
|
||||||
|
|
||||||
stats = agent.stats
|
|
||||||
|
|
||||||
# Calculate stat percentages
|
|
||||||
thirst_pct = stats.thirst / stats.MAX_THIRST
|
|
||||||
hunger_pct = stats.hunger / stats.MAX_HUNGER
|
|
||||||
heat_pct = stats.heat / stats.MAX_HEAT
|
|
||||||
energy_pct = stats.energy / stats.MAX_ENERGY
|
|
||||||
|
|
||||||
# Get resource counts
|
|
||||||
water_count = agent.get_resource_count(ResourceType.WATER)
|
|
||||||
meat_count = agent.get_resource_count(ResourceType.MEAT)
|
|
||||||
berries_count = agent.get_resource_count(ResourceType.BERRIES)
|
|
||||||
wood_count = agent.get_resource_count(ResourceType.WOOD)
|
|
||||||
hide_count = agent.get_resource_count(ResourceType.HIDE)
|
|
||||||
food_count = meat_count + berries_count
|
|
||||||
|
|
||||||
# Check market availability
|
|
||||||
def get_market_info(resource_type: ResourceType) -> tuple[bool, int]:
|
|
||||||
"""Get market availability and price for a resource."""
|
|
||||||
order = market.get_cheapest_order(resource_type)
|
|
||||||
if order and order.seller_id != agent.id and agent.money >= order.price_per_unit:
|
|
||||||
return True, order.price_per_unit
|
|
||||||
return False, 0
|
|
||||||
|
|
||||||
can_buy_water, water_price = get_market_info(ResourceType.WATER)
|
|
||||||
can_buy_meat, meat_price = get_market_info(ResourceType.MEAT)
|
|
||||||
can_buy_berries, berries_price = get_market_info(ResourceType.BERRIES)
|
|
||||||
can_buy_wood, wood_price = get_market_info(ResourceType.WOOD)
|
|
||||||
|
|
||||||
# Can buy food if we can buy either meat or berries
|
|
||||||
can_buy_food = can_buy_meat or can_buy_berries
|
|
||||||
food_price = min(
|
|
||||||
meat_price if can_buy_meat else float('inf'),
|
|
||||||
berries_price if can_buy_berries else float('inf')
|
|
||||||
)
|
|
||||||
food_price = food_price if food_price != float('inf') else 0
|
|
||||||
|
|
||||||
# Wealth calculation
|
|
||||||
min_wealth_target = getattr(economy_config, 'min_wealth_target', 50) if economy_config else 50
|
|
||||||
wealth_target = int(min_wealth_target * (0.5 + agent.personality.wealth_desire))
|
|
||||||
is_wealthy = agent.money >= wealth_target
|
|
||||||
|
|
||||||
# Trader check
|
|
||||||
is_trader = agent.personality.trade_preference > 1.3 and agent.personality.market_affinity > 0.5
|
|
||||||
|
|
||||||
# Evening check (last 2 steps before night)
|
|
||||||
is_evening = step_in_day >= day_steps - 2
|
|
||||||
|
|
||||||
return WorldState(
|
|
||||||
thirst_pct=thirst_pct,
|
|
||||||
hunger_pct=hunger_pct,
|
|
||||||
heat_pct=heat_pct,
|
|
||||||
energy_pct=energy_pct,
|
|
||||||
water_count=water_count,
|
|
||||||
food_count=food_count,
|
|
||||||
meat_count=meat_count,
|
|
||||||
berries_count=berries_count,
|
|
||||||
wood_count=wood_count,
|
|
||||||
hide_count=hide_count,
|
|
||||||
has_clothes=agent.has_clothes(),
|
|
||||||
inventory_space=agent.inventory_space(),
|
|
||||||
inventory_full=agent.inventory_full(),
|
|
||||||
money=agent.money,
|
|
||||||
is_wealthy=is_wealthy,
|
|
||||||
can_buy_water=can_buy_water,
|
|
||||||
can_buy_food=can_buy_food,
|
|
||||||
can_buy_meat=can_buy_meat,
|
|
||||||
can_buy_berries=can_buy_berries,
|
|
||||||
can_buy_wood=can_buy_wood,
|
|
||||||
water_market_price=water_price,
|
|
||||||
food_market_price=int(food_price),
|
|
||||||
wood_market_price=wood_price,
|
|
||||||
is_night=is_night,
|
|
||||||
is_evening=is_evening,
|
|
||||||
step_in_day=step_in_day,
|
|
||||||
day_steps=day_steps,
|
|
||||||
wealth_desire=agent.personality.wealth_desire,
|
|
||||||
hoarding_rate=agent.personality.hoarding_rate,
|
|
||||||
risk_tolerance=agent.personality.risk_tolerance,
|
|
||||||
market_affinity=agent.personality.market_affinity,
|
|
||||||
is_trader=is_trader,
|
|
||||||
gather_preference=agent.personality.gather_preference,
|
|
||||||
hunt_preference=agent.personality.hunt_preference,
|
|
||||||
trade_preference=agent.personality.trade_preference,
|
|
||||||
hunting_skill=agent.skills.hunting,
|
|
||||||
gathering_skill=agent.skills.gathering,
|
|
||||||
trading_skill=agent.skills.trading,
|
|
||||||
critical_threshold=agent_config.critical_threshold,
|
|
||||||
low_threshold=0.45, # Could also be in config
|
|
||||||
)
|
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ class Order:
|
|||||||
seller_id: str = ""
|
seller_id: str = ""
|
||||||
resource_type: ResourceType = ResourceType.BERRIES
|
resource_type: ResourceType = ResourceType.BERRIES
|
||||||
quantity: int = 1
|
quantity: int = 1
|
||||||
price_per_unit: int = 100 # Default to min_price from config
|
price_per_unit: int = 1
|
||||||
created_turn: int = 0
|
created_turn: int = 0
|
||||||
status: OrderStatus = OrderStatus.ACTIVE
|
status: OrderStatus = OrderStatus.ACTIVE
|
||||||
|
|
||||||
@ -62,9 +62,8 @@ class Order:
|
|||||||
|
|
||||||
def apply_discount(self, percentage: float = 0.1) -> None:
|
def apply_discount(self, percentage: float = 0.1) -> None:
|
||||||
"""Apply a discount to the price."""
|
"""Apply a discount to the price."""
|
||||||
min_price = _get_min_price()
|
|
||||||
reduction = max(1, int(self.price_per_unit * percentage))
|
reduction = max(1, int(self.price_per_unit * percentage))
|
||||||
self.price_per_unit = max(min_price, self.price_per_unit - reduction)
|
self.price_per_unit = max(1, self.price_per_unit - reduction)
|
||||||
|
|
||||||
def adjust_price(self, new_price: int, current_turn: int) -> bool:
|
def adjust_price(self, new_price: int, current_turn: int) -> bool:
|
||||||
"""Adjust the order's price. Returns True if successful."""
|
"""Adjust the order's price. Returns True if successful."""
|
||||||
@ -125,14 +124,6 @@ def _get_market_config():
|
|||||||
return get_config().market
|
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
|
@dataclass
|
||||||
class OrderBook:
|
class OrderBook:
|
||||||
"""Central market order book with supply/demand tracking.
|
"""Central market order book with supply/demand tracking.
|
||||||
@ -385,7 +376,7 @@ class OrderBook:
|
|||||||
price_multiplier = 1.0
|
price_multiplier = 1.0
|
||||||
|
|
||||||
suggested = int(reference_price * price_multiplier)
|
suggested = int(reference_price * price_multiplier)
|
||||||
return max(_get_min_price(), suggested)
|
return max(1, suggested)
|
||||||
|
|
||||||
def adjust_order_price(self, order_id: str, seller_id: str, new_price: int, current_turn: int) -> bool:
|
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."""
|
"""Adjust the price of an existing order. Returns True if successful."""
|
||||||
|
|||||||
31
config.json
31
config.json
@ -1,10 +1,4 @@
|
|||||||
{
|
{
|
||||||
"ai": {
|
|
||||||
"use_goap": true,
|
|
||||||
"goap_max_iterations": 50,
|
|
||||||
"goap_max_plan_depth": 3,
|
|
||||||
"reactive_fallback": true
|
|
||||||
},
|
|
||||||
"agent_stats": {
|
"agent_stats": {
|
||||||
"max_energy": 50,
|
"max_energy": 50,
|
||||||
"max_hunger": 100,
|
"max_hunger": 100,
|
||||||
@ -25,27 +19,27 @@
|
|||||||
"meat_decay": 10,
|
"meat_decay": 10,
|
||||||
"berries_decay": 6,
|
"berries_decay": 6,
|
||||||
"clothes_decay": 20,
|
"clothes_decay": 20,
|
||||||
"meat_hunger": 45,
|
"meat_hunger": 35,
|
||||||
"meat_energy": 15,
|
"meat_energy": 12,
|
||||||
"berries_hunger": 8,
|
"berries_hunger": 10,
|
||||||
"berries_thirst": 2,
|
"berries_thirst": 4,
|
||||||
"water_thirst": 50,
|
"water_thirst": 50,
|
||||||
"fire_heat": 20
|
"fire_heat": 20
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"sleep_energy": 55,
|
"sleep_energy": 55,
|
||||||
"rest_energy": 12,
|
"rest_energy": 12,
|
||||||
"hunt_energy": -5,
|
"hunt_energy": -7,
|
||||||
"gather_energy": -4,
|
"gather_energy": -3,
|
||||||
"chop_wood_energy": -6,
|
"chop_wood_energy": -6,
|
||||||
"get_water_energy": -2,
|
"get_water_energy": -2,
|
||||||
"weave_energy": -6,
|
"weave_energy": -6,
|
||||||
"build_fire_energy": -4,
|
"build_fire_energy": -4,
|
||||||
"trade_energy": -1,
|
"trade_energy": -1,
|
||||||
"hunt_success": 0.85,
|
"hunt_success": 0.70,
|
||||||
"chop_wood_success": 0.9,
|
"chop_wood_success": 0.90,
|
||||||
"hunt_meat_min": 2,
|
"hunt_meat_min": 2,
|
||||||
"hunt_meat_max": 4,
|
"hunt_meat_max": 5,
|
||||||
"hunt_hide_min": 0,
|
"hunt_hide_min": 0,
|
||||||
"hunt_hide_max": 2,
|
"hunt_hide_max": 2,
|
||||||
"gather_min": 2,
|
"gather_min": 2,
|
||||||
@ -60,7 +54,7 @@
|
|||||||
"day_steps": 10,
|
"day_steps": 10,
|
||||||
"night_steps": 1,
|
"night_steps": 1,
|
||||||
"inventory_slots": 12,
|
"inventory_slots": 12,
|
||||||
"starting_money": 8000
|
"starting_money": 80
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"turns_before_discount": 15,
|
"turns_before_discount": 15,
|
||||||
@ -68,11 +62,10 @@
|
|||||||
"base_price_multiplier": 1.3
|
"base_price_multiplier": 1.3
|
||||||
},
|
},
|
||||||
"economy": {
|
"economy": {
|
||||||
"energy_to_money_ratio": 150,
|
"energy_to_money_ratio": 1.5,
|
||||||
"min_price": 100,
|
|
||||||
"wealth_desire": 0.35,
|
"wealth_desire": 0.35,
|
||||||
"buy_efficiency_threshold": 0.75,
|
"buy_efficiency_threshold": 0.75,
|
||||||
"min_wealth_target": 5000,
|
"min_wealth_target": 50,
|
||||||
"max_price_markup": 2.5,
|
"max_price_markup": 2.5,
|
||||||
"min_price_discount": 0.4
|
"min_price_discount": 0.4
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@ -1,496 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
|
|
||||||
@ -1,820 +0,0 @@
|
|||||||
<!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,7 +135,6 @@
|
|||||||
<button class="tab-btn" data-tab="resources">Resources</button>
|
<button class="tab-btn" data-tab="resources">Resources</button>
|
||||||
<button class="tab-btn" data-tab="market">Market</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="agents">Agents</button>
|
||||||
<button class="tab-btn" data-tab="goap">🧠 GOAP</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-header-right">
|
<div class="stats-header-right">
|
||||||
@ -241,54 +240,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div class="stats-footer">
|
<div class="stats-footer">
|
||||||
<div class="controls">
|
|
||||||
<button id="btn-initialize-stats" class="btn btn-secondary" title="Reset Simulation">
|
|
||||||
<span class="btn-icon">⟳</span> Reset
|
|
||||||
</button>
|
|
||||||
<button id="btn-step-stats" class="btn btn-primary" title="Advance one turn">
|
|
||||||
<span class="btn-icon">▶</span> Step
|
|
||||||
</button>
|
|
||||||
<button id="btn-auto-stats" class="btn btn-toggle" title="Toggle auto mode">
|
|
||||||
<span class="btn-icon">⏯</span> Auto
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="stats-summary-bar">
|
<div class="stats-summary-bar">
|
||||||
<div class="summary-item">
|
<div class="summary-item">
|
||||||
<span class="summary-label">Turn</span>
|
<span class="summary-label">Turn</span>
|
||||||
@ -315,11 +268,6 @@
|
|||||||
<span class="summary-value" id="stats-gini">0.00</span>
|
<span class="summary-value" id="stats-gini">0.00</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="speed-control">
|
|
||||||
<label for="speed-slider-stats">Speed</label>
|
|
||||||
<input type="range" id="speed-slider-stats" min="50" max="1000" value="150" step="50">
|
|
||||||
<span id="speed-display-stats">150ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -124,21 +124,6 @@ class SimulationAPI {
|
|||||||
async getLogs(limit = 10) {
|
async getLogs(limit = 10) {
|
||||||
return await this.request(`/api/logs?limit=${limit}`);
|
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
|
// Export singleton instance
|
||||||
|
|||||||
@ -127,22 +127,7 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
statsGold: document.getElementById('stats-gold'),
|
statsGold: document.getElementById('stats-gold'),
|
||||||
statsAvgWealth: document.getElementById('stats-avg-wealth'),
|
statsAvgWealth: document.getElementById('stats-avg-wealth'),
|
||||||
statsGini: document.getElementById('stats-gini'),
|
statsGini: document.getElementById('stats-gini'),
|
||||||
// GOAP elements
|
|
||||||
goapAgentList: document.getElementById('goap-agent-list'),
|
|
||||||
goapPlanView: document.getElementById('goap-plan-view'),
|
|
||||||
goapActionsList: document.getElementById('goap-actions-list'),
|
|
||||||
chartGoapGoals: document.getElementById('chart-goap-goals'),
|
|
||||||
// Stats screen controls (duplicated for stats page)
|
|
||||||
btnStepStats: document.getElementById('btn-step-stats'),
|
|
||||||
btnAutoStats: document.getElementById('btn-auto-stats'),
|
|
||||||
btnInitializeStats: document.getElementById('btn-initialize-stats'),
|
|
||||||
speedSliderStats: document.getElementById('speed-slider-stats'),
|
|
||||||
speedDisplayStats: document.getElementById('speed-display-stats'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// GOAP state
|
|
||||||
this.goapData = null;
|
|
||||||
this.selectedGoapAgentId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@ -178,21 +163,6 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
btnCloseStats.removeEventListener('click', this.boundHandlers.closeStats);
|
btnCloseStats.removeEventListener('click', this.boundHandlers.closeStats);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats screen controls cleanup
|
|
||||||
const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats } = this.domCache;
|
|
||||||
if (btnStepStats && this.boundHandlers.step) {
|
|
||||||
btnStepStats.removeEventListener('click', this.boundHandlers.step);
|
|
||||||
}
|
|
||||||
if (btnAutoStats && this.boundHandlers.auto) {
|
|
||||||
btnAutoStats.removeEventListener('click', this.boundHandlers.auto);
|
|
||||||
}
|
|
||||||
if (btnInitializeStats && this.boundHandlers.init) {
|
|
||||||
btnInitializeStats.removeEventListener('click', this.boundHandlers.init);
|
|
||||||
}
|
|
||||||
if (speedSliderStats && this.boundHandlers.speedStats) {
|
|
||||||
speedSliderStats.removeEventListener('input', this.boundHandlers.speedStats);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy charts
|
// Destroy charts
|
||||||
Object.values(this.charts).forEach(chart => chart?.destroy());
|
Object.values(this.charts).forEach(chart => chart?.destroy());
|
||||||
this.charts = {};
|
this.charts = {};
|
||||||
@ -307,34 +277,19 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
setupUIControls() {
|
setupUIControls() {
|
||||||
const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider, speedDisplay, tabButtons } = this.domCache;
|
const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider, speedDisplay, tabButtons } = this.domCache;
|
||||||
const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats, speedDisplayStats } = this.domCache;
|
|
||||||
|
|
||||||
// Create bound handlers for later cleanup
|
// Create bound handlers for later cleanup
|
||||||
this.boundHandlers.step = () => this.handleStep();
|
this.boundHandlers.step = () => this.handleStep();
|
||||||
this.boundHandlers.auto = () => this.toggleAutoMode();
|
this.boundHandlers.auto = () => this.toggleAutoMode();
|
||||||
this.boundHandlers.init = () => this.handleInitialize();
|
this.boundHandlers.init = () => this.handleInitialize();
|
||||||
|
|
||||||
// Speed handler that syncs both sliders
|
|
||||||
this.boundHandlers.speed = (e) => {
|
this.boundHandlers.speed = (e) => {
|
||||||
this.autoSpeed = parseInt(e.target.value);
|
this.autoSpeed = parseInt(e.target.value);
|
||||||
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
|
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
|
||||||
if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`;
|
|
||||||
if (speedSliderStats) speedSliderStats.value = this.autoSpeed;
|
|
||||||
if (this.isAutoMode) this.restartAutoMode();
|
if (this.isAutoMode) this.restartAutoMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.boundHandlers.speedStats = (e) => {
|
|
||||||
this.autoSpeed = parseInt(e.target.value);
|
|
||||||
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
|
|
||||||
if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`;
|
|
||||||
if (speedSlider) speedSlider.value = this.autoSpeed;
|
|
||||||
if (this.isAutoMode) this.restartAutoMode();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.boundHandlers.openStats = () => this.showStatsScreen();
|
this.boundHandlers.openStats = () => this.showStatsScreen();
|
||||||
this.boundHandlers.closeStats = () => this.hideStatsScreen();
|
this.boundHandlers.closeStats = () => this.hideStatsScreen();
|
||||||
|
|
||||||
// Main controls
|
|
||||||
if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step);
|
if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step);
|
||||||
if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto);
|
if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto);
|
||||||
if (btnInitialize) btnInitialize.addEventListener('click', this.boundHandlers.init);
|
if (btnInitialize) btnInitialize.addEventListener('click', this.boundHandlers.init);
|
||||||
@ -342,12 +297,6 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
if (btnStats) btnStats.addEventListener('click', this.boundHandlers.openStats);
|
if (btnStats) btnStats.addEventListener('click', this.boundHandlers.openStats);
|
||||||
if (btnCloseStats) btnCloseStats.addEventListener('click', this.boundHandlers.closeStats);
|
if (btnCloseStats) btnCloseStats.addEventListener('click', this.boundHandlers.closeStats);
|
||||||
|
|
||||||
// Stats screen controls (same handlers)
|
|
||||||
if (btnStepStats) btnStepStats.addEventListener('click', this.boundHandlers.step);
|
|
||||||
if (btnAutoStats) btnAutoStats.addEventListener('click', this.boundHandlers.auto);
|
|
||||||
if (btnInitializeStats) btnInitializeStats.addEventListener('click', this.boundHandlers.init);
|
|
||||||
if (speedSliderStats) speedSliderStats.addEventListener('input', this.boundHandlers.speedStats);
|
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
tabButtons?.forEach(btn => {
|
tabButtons?.forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
|
btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
|
||||||
@ -422,19 +371,15 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
toggleAutoMode() {
|
toggleAutoMode() {
|
||||||
this.isAutoMode = !this.isAutoMode;
|
this.isAutoMode = !this.isAutoMode;
|
||||||
const { btnAuto, btnStep, btnAutoStats, btnStepStats } = this.domCache;
|
const { btnAuto, btnStep } = this.domCache;
|
||||||
|
|
||||||
if (this.isAutoMode) {
|
if (this.isAutoMode) {
|
||||||
btnAuto?.classList.add('active');
|
btnAuto?.classList.add('active');
|
||||||
btnAutoStats?.classList.add('active');
|
|
||||||
btnStep?.setAttribute('disabled', 'true');
|
btnStep?.setAttribute('disabled', 'true');
|
||||||
btnStepStats?.setAttribute('disabled', 'true');
|
|
||||||
this.startAutoMode();
|
this.startAutoMode();
|
||||||
} else {
|
} else {
|
||||||
btnAuto?.classList.remove('active');
|
btnAuto?.classList.remove('active');
|
||||||
btnAutoStats?.classList.remove('active');
|
|
||||||
btnStep?.removeAttribute('disabled');
|
btnStep?.removeAttribute('disabled');
|
||||||
btnStepStats?.removeAttribute('disabled');
|
|
||||||
this.stopAutoMode();
|
this.stopAutoMode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -796,15 +741,6 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
<span class="action-label">Current Action</span>
|
<span class="action-label">Current Action</span>
|
||||||
<div>${actionData.icon} ${action.message || actionData.verb}</div>
|
<div>${actionData.icon} ${action.message || actionData.verb}</div>
|
||||||
</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>
|
<h5 class="subsection-title">Personal Log</h5>
|
||||||
<div class="agent-log">
|
<div class="agent-log">
|
||||||
${renderActionLog()}
|
${renderActionLog()}
|
||||||
@ -1087,7 +1023,6 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
case 'resources': this.renderResourceCharts(); break;
|
case 'resources': this.renderResourceCharts(); break;
|
||||||
case 'market': this.renderMarketCharts(); break;
|
case 'market': this.renderMarketCharts(); break;
|
||||||
case 'agents': this.renderAgentStatsCharts(); break;
|
case 'agents': this.renderAgentStatsCharts(); break;
|
||||||
case 'goap': this.fetchAndRenderGOAP(); break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1692,291 +1627,6 @@ 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) {
|
update(time, delta) {
|
||||||
// Minimal update loop - no heavy operations here
|
// Minimal update loop - no heavy operations here
|
||||||
}
|
}
|
||||||
|
|||||||
@ -613,8 +613,7 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
#speed-slider,
|
#speed-slider {
|
||||||
#speed-slider-stats {
|
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
@ -624,8 +623,7 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#speed-slider::-webkit-slider-thumb,
|
#speed-slider::-webkit-slider-thumb {
|
||||||
#speed-slider-stats::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@ -635,8 +633,7 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#speed-display,
|
#speed-display {
|
||||||
#speed-display-stats {
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
min-width: 50px;
|
min-width: 50px;
|
||||||
}
|
}
|
||||||
@ -942,21 +939,16 @@ body {
|
|||||||
|
|
||||||
/* Stats Footer */
|
/* Stats Footer */
|
||||||
.stats-footer {
|
.stats-footer {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--space-sm) var(--space-lg);
|
padding: var(--space-sm) var(--space-lg);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 56px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-summary-bar {
|
.stats-summary-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-xl);
|
gap: var(--space-xl);
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-item {
|
.summary-item {
|
||||||
@ -1057,375 +1049,5 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-footer {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
height: auto;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-footer .controls {
|
|
||||||
order: 1;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-footer .stats-summary-bar {
|
|
||||||
order: 2;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-footer .speed-control {
|
|
||||||
order: 3;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =================================
|
|
||||||
GOAP Visualization Styles
|
|
||||||
================================= */
|
|
||||||
|
|
||||||
.goap-container {
|
|
||||||
padding: var(--space-lg);
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-header {
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-header h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: var(--accent-sapphire);
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 250px 1fr 300px;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
gap: var(--space-md);
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-panel {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-md);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-panel h4 {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
padding-bottom: var(--space-sm);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agents-panel {
|
|
||||||
grid-row: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-plan-panel {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-goals-panel {
|
|
||||||
grid-column: 3;
|
|
||||||
grid-row: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-actions-panel {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agent-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agent-item {
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agent-item:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agent-item.selected {
|
|
||||||
background: rgba(90, 140, 200, 0.2);
|
|
||||||
border-left: 3px solid var(--accent-sapphire);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agent-item .agent-name {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agent-item .agent-action {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agent-item .agent-goal {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--accent-sapphire);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-plan-view {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-world-state {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-stat-card {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: var(--space-sm);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-stat-card .label {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-stat-card .value {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-stat-card .bar {
|
|
||||||
height: 3px;
|
|
||||||
background: var(--bg-deep);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-top: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-stat-card .bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-stat-card.thirst .bar-fill { background: var(--stat-thirst); }
|
|
||||||
.goap-stat-card.hunger .bar-fill { background: var(--stat-hunger); }
|
|
||||||
.goap-stat-card.heat .bar-fill { background: var(--stat-heat); }
|
|
||||||
.goap-stat-card.energy .bar-fill { background: var(--stat-energy); }
|
|
||||||
|
|
||||||
.goap-plan-steps {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: var(--space-md);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-plan-steps h5 {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-plan-flow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-step-node {
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-step-node.current {
|
|
||||||
border-color: var(--accent-emerald);
|
|
||||||
background: rgba(74, 156, 109, 0.15);
|
|
||||||
color: var(--accent-emerald);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-step-arrow {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-goal-result {
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
background: rgba(139, 111, 192, 0.15);
|
|
||||||
border: 2px solid #8b6fc0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: #8b6fc0;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-inventory {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-inv-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-inv-item .count {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-actions-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-action-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-action-item.valid {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-action-item.invalid {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-action-item.in-plan {
|
|
||||||
background: rgba(74, 156, 109, 0.15);
|
|
||||||
border-left: 3px solid var(--accent-emerald);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-action-item .action-name {
|
|
||||||
flex: 1;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-action-item .action-cost {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-action-item .action-order {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: var(--accent-emerald);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--bg-deep);
|
|
||||||
margin-right: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-selection-text, .loading-text {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mini {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mini:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-goap-info {
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
padding: var(--space-sm);
|
|
||||||
background: var(--bg-deep);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-urgency {
|
|
||||||
display: inline-block;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-urgency.none { background: var(--accent-emerald); }
|
|
||||||
.goap-urgency.low { background: var(--accent-gold); }
|
|
||||||
.goap-urgency.high { background: var(--accent-ruby); }
|
|
||||||
|
|
||||||
@media (max-width: 1400px) {
|
|
||||||
.goap-grid {
|
|
||||||
grid-template-columns: 200px 1fr 250px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
.goap-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-agents-panel,
|
|
||||||
.goap-goals-panel {
|
|
||||||
grid-row: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goap-panel {
|
|
||||||
max-height: 300px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user