Compare commits

...

2 Commits
master ... goap

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

View File

@ -93,12 +93,12 @@ def get_market_prices():
) )
def initialize_simulation(request: InitializeRequest = None): def initialize_simulation(request: InitializeRequest = None):
"""Initialize or reset the simulation. """Initialize or reset the simulation.
If request is provided with specific values, use those. If request is provided with specific values, use those.
Otherwise, use values from config.json. Otherwise, use values from config.json.
""" """
engine = get_engine() engine = get_engine()
if request and (request.num_agents != 8 or request.world_width != 20 or request.world_height != 20): if request and (request.num_agents != 8 or request.world_width != 20 or request.world_height != 20):
# Custom values provided - use them # Custom values provided - use them
from backend.core.world import WorldConfig from backend.core.world import WorldConfig
@ -113,7 +113,7 @@ def initialize_simulation(request: InitializeRequest = None):
# Use values from config.json # Use values from config.json
engine.reset() # This now loads from config.json automatically engine.reset() # This now loads from config.json automatically
num_agents = engine.world.config.initial_agents num_agents = engine.world.config.initial_agents
return ControlResponse( return ControlResponse(
success=True, success=True,
message=f"Simulation initialized with {num_agents} agents", message=f"Simulation initialized with {num_agents} agents",
@ -131,21 +131,21 @@ def initialize_simulation(request: InitializeRequest = None):
def next_step(): def next_step():
"""Advance the simulation by one step.""" """Advance the simulation by one step."""
engine = get_engine() engine = get_engine()
if engine.mode == SimulationMode.AUTO: if engine.mode == SimulationMode.AUTO:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Cannot manually advance while in AUTO mode. Switch to MANUAL first.", detail="Cannot manually advance while in AUTO mode. Switch to MANUAL first.",
) )
if not engine.is_running: if not engine.is_running:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Simulation is not running. Initialize first.", detail="Simulation is not running. Initialize first.",
) )
turn_log = engine.next_step() turn_log = engine.next_step()
return ControlResponse( return ControlResponse(
success=True, success=True,
message=f"Advanced to turn {engine.world.current_turn}", message=f"Advanced to turn {engine.world.current_turn}",
@ -163,7 +163,7 @@ def next_step():
def set_mode(request: SetModeRequest): def set_mode(request: SetModeRequest):
"""Set the simulation mode.""" """Set the simulation mode."""
engine = get_engine() engine = get_engine()
try: try:
mode = SimulationMode(request.mode) mode = SimulationMode(request.mode)
engine.set_mode(mode) engine.set_mode(mode)
@ -223,11 +223,11 @@ def get_logs(limit: int = 10):
def get_turn_log(turn: int): def get_turn_log(turn: int):
"""Get log for a specific turn.""" """Get log for a specific turn."""
engine = get_engine() engine = get_engine()
for log in engine.turn_logs: for log in engine.turn_logs:
if log.turn == turn: if log.turn == turn:
return log.to_dict() return log.to_dict()
raise HTTPException(status_code=404, detail=f"Log for turn {turn} not found") raise HTTPException(status_code=404, detail=f"Log for turn {turn} not found")
@ -315,3 +315,107 @@ 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),
}

View File

@ -14,19 +14,19 @@ class AgentStatsConfig:
max_hunger: int = 100 max_hunger: int = 100
max_thirst: int = 100 # Increased from 50 to give more buffer max_thirst: int = 100 # Increased from 50 to give more buffer
max_heat: int = 100 max_heat: int = 100
# Starting values # Starting values
start_energy: int = 50 start_energy: int = 50
start_hunger: int = 80 start_hunger: int = 80
start_thirst: int = 80 # Increased from 40 to start with more buffer start_thirst: int = 80 # Increased from 40 to start with more buffer
start_heat: int = 100 start_heat: int = 100
# Decay rates per turn # Decay rates per turn
energy_decay: int = 2 energy_decay: int = 2
hunger_decay: int = 2 hunger_decay: int = 2
thirst_decay: int = 2 # Reduced from 3 to match hunger decay rate thirst_decay: int = 2 # Reduced from 3 to match hunger decay rate
heat_decay: int = 2 heat_decay: int = 2
# Thresholds # Thresholds
critical_threshold: float = 0.25 # 25% triggers survival mode critical_threshold: float = 0.25 # 25% triggers survival mode
low_energy_threshold: int = 15 # Minimum energy to work low_energy_threshold: int = 15 # Minimum energy to work
@ -39,7 +39,7 @@ class ResourceConfig:
meat_decay: int = 8 # Increased from 5 to give more time to use meat_decay: int = 8 # Increased from 5 to give more time to use
berries_decay: int = 25 berries_decay: int = 25
clothes_decay: int = 50 clothes_decay: int = 50
# Resource effects # Resource effects
meat_hunger: int = 30 meat_hunger: int = 30
meat_energy: int = 5 meat_energy: int = 5
@ -62,11 +62,11 @@ class ActionConfig:
weave_energy: int = -8 weave_energy: int = -8
build_fire_energy: int = -5 build_fire_energy: int = -5
trade_energy: int = -1 trade_energy: int = -1
# Success chances (0.0 to 1.0) # Success chances (0.0 to 1.0)
hunt_success: float = 0.7 hunt_success: float = 0.7
chop_wood_success: float = 0.9 chop_wood_success: float = 0.9
# Output quantities # Output quantities
hunt_meat_min: int = 1 hunt_meat_min: int = 1
hunt_meat_max: int = 3 hunt_meat_max: int = 3
@ -86,7 +86,7 @@ class WorldConfig:
initial_agents: int = 8 initial_agents: int = 8
day_steps: int = 10 day_steps: int = 10
night_steps: int = 1 night_steps: int = 1
# Agent configuration # Agent configuration
inventory_slots: int = 10 inventory_slots: int = 10
starting_money: int = 100 starting_money: int = 100
@ -103,31 +103,54 @@ class MarketConfig:
@dataclass @dataclass
class EconomyConfig: class EconomyConfig:
"""Configuration for economic behavior and agent trading. """Configuration for economic behavior and agent trading.
These values control how agents perceive the value of money and trading. These values control how agents perceive the value of money and trading.
Higher values make agents more trade-oriented. Higher values make agents more trade-oriented.
""" """
# 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 = 1.5 # 1 energy ≈ 1.5 coins energy_to_money_ratio: float = 150 # 1 energy ≈ 150 coins
# Minimum price floor for any market transaction
min_price: int = 100
# How strongly agents desire wealth (0-1) # How strongly agents desire wealth (0-1)
# Higher = agents will prioritize building wealth # Higher = agents will prioritize building wealth
wealth_desire: float = 0.3 wealth_desire: float = 0.3
# Buy efficiency threshold (0-1) # Buy efficiency threshold (0-1)
# If market price < (threshold * fair_value), buy instead of gather # If market price < (threshold * fair_value), buy instead of gather
# 0.7 means: buy if price is 70% or less of the fair value # 0.7 means: buy if price is 70% or less of the fair value
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 = 50 min_wealth_target: int = 5000
# 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."""
@ -137,13 +160,15 @@ 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
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),
@ -152,11 +177,12 @@ class SimulationConfig:
"economy": asdict(self.economy), "economy": asdict(self.economy),
"auto_step_interval": self.auto_step_interval, "auto_step_interval": self.auto_step_interval,
} }
@classmethod @classmethod
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", {})),
@ -165,12 +191,12 @@ class SimulationConfig:
economy=EconomyConfig(**data.get("economy", {})), economy=EconomyConfig(**data.get("economy", {})),
auto_step_interval=data.get("auto_step_interval", 1.0), auto_step_interval=data.get("auto_step_interval", 1.0),
) )
def save(self, path: str = "config.json") -> None: def save(self, path: str = "config.json") -> None:
"""Save configuration to JSON file.""" """Save configuration to JSON file."""
with open(path, "w") as f: with open(path, "w") as f:
json.dump(self.to_dict(), f, indent=2) json.dump(self.to_dict(), f, indent=2)
@classmethod @classmethod
def load(cls, path: str = "config.json") -> "SimulationConfig": def load(cls, path: str = "config.json") -> "SimulationConfig":
"""Load configuration from JSON file.""" """Load configuration from JSON file."""
@ -188,7 +214,7 @@ _config: Optional[SimulationConfig] = None
def get_config() -> SimulationConfig: def get_config() -> SimulationConfig:
"""Get the global configuration instance. """Get the global configuration instance.
Loads from config.json if not already loaded. Loads from config.json if not already loaded.
""" """
global _config global _config
@ -246,7 +272,7 @@ def _reset_all_caches() -> None:
reset_action_config_cache() reset_action_config_cache()
except ImportError: except ImportError:
pass pass
try: try:
from backend.domain.resources import reset_resource_cache from backend.domain.resources import reset_resource_cache
reset_resource_cache() reset_resource_cache()

File diff suppressed because it is too large Load Diff

View File

@ -171,21 +171,19 @@ class GameEngine:
money=agent.money, money=agent.money,
) )
if self.world.is_night(): # Get AI config to determine which system to use
# Force sleep at night ai_config = get_config().ai
decision = AIDecision(
action=ActionType.SLEEP, # GOAP AI handles night time automatically
reason="Night time: sleeping", decision = get_ai_decision(
) agent,
else: self.market,
# Pass time info so AI can prepare for night step_in_day=self.world.step_in_day,
decision = get_ai_decision( day_steps=self.world.config.day_steps,
agent, current_turn=current_turn,
self.market, use_goap=ai_config.use_goap,
step_in_day=self.world.step_in_day, is_night=self.world.is_night(),
day_steps=self.world.config.day_steps, )
current_turn=current_turn,
)
decisions.append((agent, decision)) decisions.append((agent, decision))

View File

@ -0,0 +1,39 @@
"""GOAP (Goal-Oriented Action Planning) module for agent decision making.
This module provides a GOAP-based AI system where agents:
1. Evaluate their current world state
2. Select the most relevant goal based on priorities
3. Plan a sequence of actions to achieve that goal
4. Execute the first action in the plan
Key components:
- WorldState: Dictionary-like representation of agent/world state
- Goal: Goals with dynamic priority calculation
- GOAPAction: Actions with preconditions and effects
- Planner: A* search for finding optimal action sequences
"""
from .world_state import WorldState
from .goal import Goal, GoalType
from .action import GOAPAction
from .planner import GOAPPlanner
from .goals import SURVIVAL_GOALS, ECONOMIC_GOALS, get_all_goals
from .actions import get_all_actions, get_action_by_type
from .debug import GOAPDebugInfo, get_goap_debug_info, get_all_agents_goap_debug
__all__ = [
'WorldState',
'Goal',
'GoalType',
'GOAPAction',
'GOAPPlanner',
'SURVIVAL_GOALS',
'ECONOMIC_GOALS',
'get_all_goals',
'get_all_actions',
'get_action_by_type',
'GOAPDebugInfo',
'get_goap_debug_info',
'get_all_agents_goap_debug',
]

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

@ -0,0 +1,419 @@
"""GOAP Action definitions.
Actions are the building blocks of plans. Each action has:
- Preconditions: What must be true for the action to be valid
- Effects: How the action changes the world state
- Cost: How expensive the action is (for planning)
"""
from dataclasses import dataclass, field
from typing import Callable, Optional, TYPE_CHECKING
from backend.domain.action import ActionType
from backend.domain.resources import ResourceType
from .world_state import WorldState
if TYPE_CHECKING:
from backend.domain.agent import Agent
from backend.core.market import OrderBook
@dataclass
class GOAPAction:
"""A GOAP action that can be part of a plan.
Actions transform the world state. The planner uses preconditions
and effects to search for valid action sequences.
Attributes:
name: Human-readable name
action_type: The underlying ActionType to execute
target_resource: Optional resource this action targets
preconditions: Function that checks if action is valid in a state
effects: Function that returns the expected effects on state
cost: Function that calculates action cost (lower = preferred)
get_decision_params: Function to get parameters for AIDecision
"""
name: str
action_type: ActionType
target_resource: Optional[ResourceType] = None
# Functions that evaluate in context of world state
preconditions: Callable[[WorldState], bool] = field(default=lambda s: True)
effects: Callable[[WorldState], dict] = field(default=lambda s: {})
cost: Callable[[WorldState], float] = field(default=lambda s: 1.0)
# For generating the actual decision
get_decision_params: Optional[Callable[[WorldState, "Agent", "OrderBook"], dict]] = None
def is_valid(self, state: WorldState) -> bool:
"""Check if this action can be performed in the given state."""
return self.preconditions(state)
def apply(self, state: WorldState) -> WorldState:
"""Apply this action's effects to a state, returning a new state.
This is used by the planner for forward search.
"""
new_state = state.copy()
effects = self.effects(state)
for key, value in effects.items():
if hasattr(new_state, key):
if isinstance(value, (int, float)):
# For numeric values, handle both absolute and relative changes
current = getattr(new_state, key)
if isinstance(current, bool):
setattr(new_state, key, bool(value))
else:
setattr(new_state, key, value)
else:
setattr(new_state, key, value)
# Recalculate urgencies
new_state._calculate_urgencies()
return new_state
def get_cost(self, state: WorldState) -> float:
"""Get the cost of this action in the given state."""
return self.cost(state)
def __repr__(self) -> str:
resource = f"({self.target_resource.value})" if self.target_resource else ""
return f"GOAPAction({self.name}{resource})"
def __hash__(self) -> int:
return hash((self.name, self.action_type, self.target_resource))
def __eq__(self, other) -> bool:
if not isinstance(other, GOAPAction):
return False
return (self.name == other.name and
self.action_type == other.action_type and
self.target_resource == other.target_resource)
def create_consume_action(
resource_type: ResourceType,
stat_name: str,
stat_increase: float,
secondary_stat: Optional[str] = None,
secondary_increase: float = 0.0,
) -> GOAPAction:
"""Factory for creating consume resource actions."""
count_name = f"{resource_type.value}_count" if resource_type != ResourceType.BERRIES else "berries_count"
if resource_type == ResourceType.MEAT:
count_name = "meat_count"
elif resource_type == ResourceType.WATER:
count_name = "water_count"
# Map stat name to pct name
pct_name = f"{stat_name}_pct"
secondary_pct = f"{secondary_stat}_pct" if secondary_stat else None
def preconditions(state: WorldState) -> bool:
count = getattr(state, count_name, 0)
return count > 0
def effects(state: WorldState) -> dict:
result = {}
current = getattr(state, pct_name)
result[pct_name] = min(1.0, current + stat_increase)
if secondary_pct:
current_sec = getattr(state, secondary_pct)
result[secondary_pct] = min(1.0, current_sec + secondary_increase)
# Reduce resource count
current_count = getattr(state, count_name)
result[count_name] = max(0, current_count - 1)
# Update food count if consuming food
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
result["food_count"] = max(0, state.food_count - 1)
return result
def cost(state: WorldState) -> float:
# Consuming is very cheap - 0 energy cost
return 0.5
return GOAPAction(
name=f"Consume {resource_type.value}",
action_type=ActionType.CONSUME,
target_resource=resource_type,
preconditions=preconditions,
effects=effects,
cost=cost,
)
def create_gather_action(
action_type: ActionType,
resource_type: ResourceType,
energy_cost: float,
expected_output: int,
success_chance: float = 1.0,
) -> GOAPAction:
"""Factory for creating resource gathering actions."""
count_name = f"{resource_type.value}_count"
if resource_type == ResourceType.BERRIES:
count_name = "berries_count"
elif resource_type == ResourceType.MEAT:
count_name = "meat_count"
def preconditions(state: WorldState) -> bool:
# Need enough energy and inventory space
energy_needed = abs(energy_cost) / 50.0 # Convert to percentage
return state.energy_pct >= energy_needed + 0.05 and state.inventory_space > 0
def effects(state: WorldState) -> dict:
result = {}
# Spend energy
energy_spent = abs(energy_cost) / 50.0
result["energy_pct"] = max(0, state.energy_pct - energy_spent)
# Gain resources (adjusted for success chance)
effective_output = int(expected_output * success_chance)
current = getattr(state, count_name)
result[count_name] = current + effective_output
# Update food count if gathering food
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
result["food_count"] = state.food_count + effective_output
# Update inventory space
result["inventory_space"] = max(0, state.inventory_space - effective_output)
return result
def cost(state: WorldState) -> float:
# Calculate cost based on efficiency (energy per unit of food)
food_per_action = expected_output * success_chance
if food_per_action > 0:
base_cost = abs(energy_cost) / food_per_action * 0.5
else:
base_cost = abs(energy_cost) / 5.0
# Adjust for success chance (penalize unreliable actions slightly)
if success_chance < 1.0:
base_cost *= 1.0 + (1.0 - success_chance) * 0.3
# STRONG profession specialization effect for gathering
if action_type == ActionType.GATHER:
# Compare gather_preference to other preferences
# Specialists get big discounts, generalists pay penalty
other_prefs = (state.hunt_preference + state.trade_preference) / 2
relative_strength = state.gather_preference / max(0.1, other_prefs)
# relative_strength > 1.0 means gathering is your specialty
# relative_strength < 1.0 means you're NOT a gatherer
if relative_strength >= 1.0:
# Specialist discount: up to 50% off
preference_modifier = 1.0 / relative_strength
else:
# Non-specialist penalty: up to 3x cost
preference_modifier = 1.0 + (1.0 - relative_strength) * 2.0
base_cost *= preference_modifier
# Skill reduces cost further (experienced = efficient)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.gathering_skill * 0.4
base_cost *= skill_modifier
return base_cost
return GOAPAction(
name=f"{action_type.value}",
action_type=action_type,
target_resource=resource_type,
preconditions=preconditions,
effects=effects,
cost=cost,
)
def create_buy_action(resource_type: ResourceType) -> GOAPAction:
"""Factory for creating market buy actions."""
can_buy_name = f"can_buy_{resource_type.value}"
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
can_buy_name = "can_buy_food" # Simplified - we check specific later
count_name = f"{resource_type.value}_count"
if resource_type == ResourceType.BERRIES:
count_name = "berries_count"
elif resource_type == ResourceType.MEAT:
count_name = "meat_count"
price_name = f"{resource_type.value}_market_price"
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
price_name = "food_market_price"
def preconditions(state: WorldState) -> bool:
# Check specific availability
if resource_type == ResourceType.MEAT:
can_buy = state.can_buy_meat
elif resource_type == ResourceType.BERRIES:
can_buy = state.can_buy_berries
else:
can_buy = getattr(state, f"can_buy_{resource_type.value}", False)
return can_buy and state.inventory_space > 0
def effects(state: WorldState) -> dict:
result = {}
# Get price
if resource_type == ResourceType.MEAT:
price = state.food_market_price
elif resource_type == ResourceType.BERRIES:
price = state.food_market_price
else:
price = getattr(state, price_name, 10)
# Spend money
result["money"] = state.money - price
# Gain resource
current = getattr(state, count_name)
result[count_name] = current + 1
# Update food count if buying food
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
result["food_count"] = state.food_count + 1
# Spend small energy
result["energy_pct"] = max(0, state.energy_pct - 0.02)
# Update inventory
result["inventory_space"] = max(0, state.inventory_space - 1)
return result
def cost(state: WorldState) -> float:
# Trading cost is low (1 energy)
base_cost = 0.5
# MILD profession effect for trading (everyone should be able to trade)
# Traders get a bonus, but non-traders shouldn't be heavily penalized
# (trading benefits the whole economy)
other_prefs = (state.hunt_preference + state.gather_preference) / 2
relative_strength = state.trade_preference / max(0.1, other_prefs)
if relative_strength >= 1.0:
# Specialist discount: up to 40% off for dedicated traders
preference_modifier = max(0.6, 1.0 / relative_strength)
else:
# Mild non-specialist penalty: up to 50% cost increase
preference_modifier = 1.0 + (1.0 - relative_strength) * 0.5
base_cost *= preference_modifier
# Skill reduces cost (experienced traders are efficient)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.trading_skill * 0.4
base_cost *= skill_modifier
# Market affinity still has mild effect
base_cost *= (1.2 - state.market_affinity * 0.4)
# Check if it's a good deal
if resource_type == ResourceType.MEAT:
price = state.food_market_price
elif resource_type == ResourceType.BERRIES:
price = state.food_market_price
else:
price = getattr(state, price_name, 100)
# Higher price = higher cost (scaled for 100-500g price range)
# At fair value (~150g), multiplier is ~1.5x
# At min price (100g), multiplier is ~1.33x
base_cost *= (1.0 + price / 300.0)
return base_cost
return GOAPAction(
name=f"Buy {resource_type.value}",
action_type=ActionType.TRADE,
target_resource=resource_type,
preconditions=preconditions,
effects=effects,
cost=cost,
)
def create_rest_action() -> GOAPAction:
"""Create the rest action."""
def preconditions(state: WorldState) -> bool:
return state.energy_pct < 0.9 # Only rest if not full
def effects(state: WorldState) -> dict:
# Rest restores energy (12 out of 50 = 0.24)
return {
"energy_pct": min(1.0, state.energy_pct + 0.24),
}
def cost(state: WorldState) -> float:
# Resting is cheap but we prefer productive actions
return 2.0
return GOAPAction(
name="Rest",
action_type=ActionType.REST,
preconditions=preconditions,
effects=effects,
cost=cost,
)
def create_build_fire_action() -> GOAPAction:
"""Create the build fire action."""
def preconditions(state: WorldState) -> bool:
return state.wood_count > 0 and state.energy_pct >= 0.1
def effects(state: WorldState) -> dict:
return {
"heat_pct": min(1.0, state.heat_pct + 0.20), # Fire gives 20 heat out of 100
"wood_count": max(0, state.wood_count - 1),
"energy_pct": max(0, state.energy_pct - 0.08), # 4 energy cost
}
def cost(state: WorldState) -> float:
# Building fire is relatively cheap when we have wood
return 1.5
return GOAPAction(
name="Build Fire",
action_type=ActionType.BUILD_FIRE,
target_resource=ResourceType.WOOD,
preconditions=preconditions,
effects=effects,
cost=cost,
)
def create_sleep_action() -> GOAPAction:
"""Create the sleep action (for night)."""
def preconditions(state: WorldState) -> bool:
return state.is_night
def effects(state: WorldState) -> dict:
return {
"energy_pct": 1.0, # Full energy restore
}
def cost(state: WorldState) -> float:
return 0.0 # Sleep is mandatory at night
return GOAPAction(
name="Sleep",
action_type=ActionType.SLEEP,
preconditions=preconditions,
effects=effects,
cost=cost,
)

View File

@ -0,0 +1,399 @@
"""Predefined GOAP actions for agents.
Actions are organized by category:
- Consume actions: Use resources from inventory
- Gather actions: Produce resources
- Trade actions: Buy/sell on market
- Utility actions: Rest, sleep, build fire
"""
from typing import Optional, TYPE_CHECKING
from backend.domain.action import ActionType, ACTION_CONFIG
from backend.domain.resources import ResourceType
from .world_state import WorldState
from .action import (
GOAPAction,
create_consume_action,
create_gather_action,
create_buy_action,
create_rest_action,
create_build_fire_action,
create_sleep_action,
)
if TYPE_CHECKING:
from backend.domain.agent import Agent
from backend.core.market import OrderBook
def _get_action_configs():
"""Get action configurations from config."""
return ACTION_CONFIG
# =============================================================================
# CONSUME ACTIONS
# =============================================================================
def _create_drink_water() -> GOAPAction:
"""Drink water to restore thirst."""
return create_consume_action(
resource_type=ResourceType.WATER,
stat_name="thirst",
stat_increase=0.50, # 50 thirst out of 100
)
def _create_eat_meat() -> GOAPAction:
"""Eat meat to restore hunger (primary food source)."""
return create_consume_action(
resource_type=ResourceType.MEAT,
stat_name="hunger",
stat_increase=0.35, # 35 hunger
secondary_stat="energy",
secondary_increase=0.24, # 12 energy
)
def _create_eat_berries() -> GOAPAction:
"""Eat berries to restore hunger and some thirst."""
return create_consume_action(
resource_type=ResourceType.BERRIES,
stat_name="hunger",
stat_increase=0.10, # 10 hunger
secondary_stat="thirst",
secondary_increase=0.04, # 4 thirst
)
CONSUME_ACTIONS = [
_create_drink_water(),
_create_eat_meat(),
_create_eat_berries(),
]
# =============================================================================
# GATHER ACTIONS
# =============================================================================
def _create_get_water() -> GOAPAction:
"""Get water from the river."""
config = _get_action_configs()[ActionType.GET_WATER]
return create_gather_action(
action_type=ActionType.GET_WATER,
resource_type=ResourceType.WATER,
energy_cost=config.energy_cost,
expected_output=1,
success_chance=1.0,
)
def _create_gather_berries() -> GOAPAction:
"""Gather berries (safe, reliable)."""
config = _get_action_configs()[ActionType.GATHER]
expected = (config.min_output + config.max_output) // 2
return create_gather_action(
action_type=ActionType.GATHER,
resource_type=ResourceType.BERRIES,
energy_cost=config.energy_cost,
expected_output=expected,
success_chance=1.0,
)
def _create_hunt() -> GOAPAction:
"""Hunt for meat (risky, high reward).
Hunt should be attractive because:
- Meat gives much more hunger than berries (35 vs 10)
- Meat also gives energy (12)
- You also get hide for clothes
Cost is balanced against gathering:
- Hunt: -7 energy, 70% success, 2-5 meat + 0-2 hide
- Gather: -3 energy, 100% success, 2-4 berries
Effective food per energy:
- Hunt: 3.5 meat avg * 0.7 = 2.45 meat = 2.45 * 35 hunger = 85.75 hunger for 7 energy = 12.25 hunger/energy
- Gather: 3 berries avg * 1.0 = 3 berries = 3 * 10 hunger = 30 hunger for 3 energy = 10 hunger/energy
So hunting is actually MORE efficient per energy for hunger! The cost should reflect this.
"""
config = _get_action_configs()[ActionType.HUNT]
expected = (config.min_output + config.max_output) // 2
# Custom preconditions for hunting
def preconditions(state: WorldState) -> bool:
# Need more energy for hunting (but not excessively so)
energy_needed = abs(config.energy_cost) / 50.0 + 0.05
return state.energy_pct >= energy_needed and state.inventory_space >= 2
def effects(state: WorldState) -> dict:
# Account for success chance
effective_meat = int(expected * config.success_chance)
effective_hide = int(1 * config.success_chance) # Average hide
energy_spent = abs(config.energy_cost) / 50.0
return {
"energy_pct": max(0, state.energy_pct - energy_spent),
"meat_count": state.meat_count + effective_meat,
"food_count": state.food_count + effective_meat,
"hide_count": state.hide_count + effective_hide,
"inventory_space": max(0, state.inventory_space - effective_meat - effective_hide),
}
def cost(state: WorldState) -> float:
# Hunt should be comparable to gather when considering value:
# - Hunt gives 3.5 meat avg (35 hunger each) = 122.5 hunger value
# - Gather gives 3 berries avg (10 hunger each) = 30 hunger value
# Hunt is 4x more valuable for hunger! So cost can be higher but not 4x.
# Base cost similar to gather
base_cost = 0.6
# Success chance penalty (small)
if config.success_chance < 1.0:
base_cost *= 1.0 + (1.0 - config.success_chance) * 0.2
# STRONG profession specialization effect for hunting
# Compare hunt_preference to other preferences
other_prefs = (state.gather_preference + state.trade_preference) / 2
relative_strength = state.hunt_preference / max(0.1, other_prefs)
# relative_strength > 1.0 means hunting is your specialty
if relative_strength >= 1.0:
# Specialist discount: up to 50% off
preference_modifier = 1.0 / relative_strength
else:
# Non-specialist penalty: up to 3x cost
preference_modifier = 1.0 + (1.0 - relative_strength) * 2.0
base_cost *= preference_modifier
# Skill reduces cost further (experienced hunters are efficient)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.hunting_skill * 0.4
base_cost *= skill_modifier
# Risk tolerance still has mild effect
risk_modifier = 1.0 + (0.5 - state.risk_tolerance) * 0.15
base_cost *= risk_modifier
# Big bonus if we have no meat - prioritize getting some
if state.meat_count == 0:
base_cost *= 0.6
# Bonus if low on food in general
if state.food_count < 2:
base_cost *= 0.8
return base_cost
return GOAPAction(
name="Hunt",
action_type=ActionType.HUNT,
target_resource=ResourceType.MEAT,
preconditions=preconditions,
effects=effects,
cost=cost,
)
def _create_chop_wood() -> GOAPAction:
"""Chop wood for fires."""
config = _get_action_configs()[ActionType.CHOP_WOOD]
expected = (config.min_output + config.max_output) // 2
return create_gather_action(
action_type=ActionType.CHOP_WOOD,
resource_type=ResourceType.WOOD,
energy_cost=config.energy_cost,
expected_output=expected,
success_chance=config.success_chance,
)
def _create_weave_clothes() -> GOAPAction:
"""Craft clothes from hide."""
config = _get_action_configs()[ActionType.WEAVE]
def preconditions(state: WorldState) -> bool:
return (
state.hide_count >= 1 and
not state.has_clothes and
state.energy_pct >= abs(config.energy_cost) / 50.0 + 0.05
)
def effects(state: WorldState) -> dict:
return {
"has_clothes": True,
"hide_count": state.hide_count - 1,
"energy_pct": max(0, state.energy_pct - abs(config.energy_cost) / 50.0),
}
def cost(state: WorldState) -> float:
return abs(config.energy_cost) / 3.0
return GOAPAction(
name="Weave Clothes",
action_type=ActionType.WEAVE,
target_resource=ResourceType.CLOTHES,
preconditions=preconditions,
effects=effects,
cost=cost,
)
GATHER_ACTIONS = [
_create_get_water(),
_create_gather_berries(),
_create_hunt(),
_create_chop_wood(),
_create_weave_clothes(),
]
# =============================================================================
# TRADE ACTIONS
# =============================================================================
def _create_buy_water() -> GOAPAction:
"""Buy water from the market."""
return create_buy_action(ResourceType.WATER)
def _create_buy_meat() -> GOAPAction:
"""Buy meat from the market."""
return create_buy_action(ResourceType.MEAT)
def _create_buy_berries() -> GOAPAction:
"""Buy berries from the market."""
return create_buy_action(ResourceType.BERRIES)
def _create_buy_wood() -> GOAPAction:
"""Buy wood from the market."""
return create_buy_action(ResourceType.WOOD)
def _create_sell_action(resource_type: ResourceType, min_keep: int = 1) -> GOAPAction:
"""Factory for creating sell actions."""
count_name = f"{resource_type.value}_count"
if resource_type == ResourceType.BERRIES:
count_name = "berries_count"
elif resource_type == ResourceType.MEAT:
count_name = "meat_count"
def preconditions(state: WorldState) -> bool:
current = getattr(state, count_name)
return current > min_keep and state.energy_pct >= 0.05
def effects(state: WorldState) -> dict:
# Estimate we'll get a reasonable price (around min_price from config)
# This is approximate - actual execution will get real prices
estimated_price = 100 # Base estimate (min_price from config)
current = getattr(state, count_name)
sell_qty = min(3, current - min_keep) # Sell up to 3, keep minimum
result = {
"money": state.money + estimated_price * sell_qty,
count_name: current - sell_qty,
"inventory_space": state.inventory_space + sell_qty,
"energy_pct": max(0, state.energy_pct - 0.02),
}
# Update food count if selling food
if resource_type in [ResourceType.MEAT, ResourceType.BERRIES]:
result["food_count"] = state.food_count - sell_qty
return result
def cost(state: WorldState) -> float:
# Selling has low cost - everyone should be able to sell excess
base_cost = 1.0
# MILD profession effect for selling (everyone should be able to trade)
other_prefs = (state.hunt_preference + state.gather_preference) / 2
relative_strength = state.trade_preference / max(0.1, other_prefs)
if relative_strength >= 1.0:
# Specialist discount: up to 40% off for dedicated traders
preference_modifier = max(0.6, 1.0 / relative_strength)
else:
# Mild non-specialist penalty: up to 50% cost increase
preference_modifier = 1.0 + (1.0 - relative_strength) * 0.5
base_cost *= preference_modifier
# Skill reduces cost (experienced traders know the market)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.trading_skill * 0.4
base_cost *= skill_modifier
# Hoarders reluctant to sell (mild effect)
base_cost *= (0.8 + state.hoarding_rate * 0.4)
return base_cost
return GOAPAction(
name=f"Sell {resource_type.value}",
action_type=ActionType.TRADE,
target_resource=resource_type,
preconditions=preconditions,
effects=effects,
cost=cost,
)
TRADE_ACTIONS = [
_create_buy_water(),
_create_buy_meat(),
_create_buy_berries(),
_create_buy_wood(),
_create_sell_action(ResourceType.WATER, min_keep=2),
_create_sell_action(ResourceType.MEAT, min_keep=1),
_create_sell_action(ResourceType.BERRIES, min_keep=2),
_create_sell_action(ResourceType.WOOD, min_keep=1),
_create_sell_action(ResourceType.HIDE, min_keep=0),
]
# =============================================================================
# UTILITY ACTIONS
# =============================================================================
UTILITY_ACTIONS = [
create_rest_action(),
create_build_fire_action(),
create_sleep_action(),
]
# =============================================================================
# ALL ACTIONS
# =============================================================================
def get_all_actions() -> list[GOAPAction]:
"""Get all available GOAP actions."""
return CONSUME_ACTIONS + GATHER_ACTIONS + TRADE_ACTIONS + UTILITY_ACTIONS
def get_action_by_type(action_type: ActionType) -> list[GOAPAction]:
"""Get all GOAP actions of a specific type."""
all_actions = get_all_actions()
return [a for a in all_actions if a.action_type == action_type]
def get_action_by_name(name: str) -> Optional[GOAPAction]:
"""Get a specific action by name."""
all_actions = get_all_actions()
for action in all_actions:
if action.name == name:
return action
return None

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

@ -0,0 +1,258 @@
"""GOAP Debug utilities for visualization and analysis.
Provides detailed information about GOAP decision-making for debugging
and visualization purposes.
"""
from dataclasses import dataclass, field
from typing import Optional, TYPE_CHECKING
from .world_state import WorldState, create_world_state
from .goal import Goal
from .action import GOAPAction
from .planner import GOAPPlanner, ReactivePlanner, Plan
from .goals import get_all_goals
from .actions import get_all_actions
if TYPE_CHECKING:
from backend.domain.agent import Agent
from backend.core.market import OrderBook
@dataclass
class GoalDebugInfo:
"""Debug information for a single goal."""
name: str
goal_type: str
priority: float
is_satisfied: bool
is_selected: bool = False
def to_dict(self) -> dict:
return {
"name": self.name,
"goal_type": self.goal_type,
"priority": round(self.priority, 2),
"is_satisfied": self.is_satisfied,
"is_selected": self.is_selected,
}
@dataclass
class ActionDebugInfo:
"""Debug information for a single action."""
name: str
action_type: str
target_resource: Optional[str]
is_valid: bool
cost: float
is_in_plan: bool = False
plan_order: int = -1
def to_dict(self) -> dict:
return {
"name": self.name,
"action_type": self.action_type,
"target_resource": self.target_resource,
"is_valid": self.is_valid,
"cost": round(self.cost, 2),
"is_in_plan": self.is_in_plan,
"plan_order": self.plan_order,
}
@dataclass
class PlanDebugInfo:
"""Debug information for the current plan."""
goal_name: str
actions: list[str]
total_cost: float
plan_length: int
def to_dict(self) -> dict:
return {
"goal_name": self.goal_name,
"actions": self.actions,
"total_cost": round(self.total_cost, 2),
"plan_length": self.plan_length,
}
@dataclass
class GOAPDebugInfo:
"""Complete GOAP debug information for an agent."""
agent_id: str
agent_name: str
world_state: dict
goals: list[GoalDebugInfo]
actions: list[ActionDebugInfo]
current_plan: Optional[PlanDebugInfo]
selected_action: Optional[str]
decision_reason: str
def to_dict(self) -> dict:
return {
"agent_id": self.agent_id,
"agent_name": self.agent_name,
"world_state": self.world_state,
"goals": [g.to_dict() for g in self.goals],
"actions": [a.to_dict() for a in self.actions],
"current_plan": self.current_plan.to_dict() if self.current_plan else None,
"selected_action": self.selected_action,
"decision_reason": self.decision_reason,
}
def get_goap_debug_info(
agent: "Agent",
market: "OrderBook",
step_in_day: int = 1,
day_steps: int = 10,
is_night: bool = False,
) -> GOAPDebugInfo:
"""Get detailed GOAP debug information for an agent.
This function performs the same planning as the actual AI,
but captures detailed information about the decision process.
"""
# Create world state
state = create_world_state(
agent=agent,
market=market,
step_in_day=step_in_day,
day_steps=day_steps,
is_night=is_night,
)
# Get goals and actions
all_goals = get_all_goals()
all_actions = get_all_actions()
# Evaluate all goals
goal_infos = []
selected_goal = None
selected_plan = None
# Sort by priority
goals_with_priority = []
for goal in all_goals:
priority = goal.priority(state)
satisfied = goal.satisfied(state)
goals_with_priority.append((goal, priority, satisfied))
goals_with_priority.sort(key=lambda x: x[1], reverse=True)
# Try planning for each goal
planner = GOAPPlanner(max_iterations=50)
for goal, priority, satisfied in goals_with_priority:
if priority > 0 and not satisfied:
plan = planner.plan(state, goal, all_actions)
if plan and not plan.is_empty:
selected_goal = goal
selected_plan = plan
break
# Build goal debug info
for goal, priority, satisfied in goals_with_priority:
info = GoalDebugInfo(
name=goal.name,
goal_type=goal.goal_type.value,
priority=priority,
is_satisfied=satisfied,
is_selected=(goal == selected_goal),
)
goal_infos.append(info)
# Build action debug info
action_infos = []
plan_action_names = []
if selected_plan:
plan_action_names = [a.name for a in selected_plan.actions]
for action in all_actions:
is_valid = action.is_valid(state)
cost = action.get_cost(state) if is_valid else float('inf')
in_plan = action.name in plan_action_names
order = plan_action_names.index(action.name) if in_plan else -1
info = ActionDebugInfo(
name=action.name,
action_type=action.action_type.value,
target_resource=action.target_resource.value if action.target_resource else None,
is_valid=is_valid,
cost=cost if cost != float('inf') else -1,
is_in_plan=in_plan,
plan_order=order,
)
action_infos.append(info)
# Sort actions: plan actions first (by order), then valid actions, then invalid
action_infos.sort(key=lambda a: (
0 if a.is_in_plan else 1,
a.plan_order if a.is_in_plan else 999,
0 if a.is_valid else 1,
a.cost if a.cost >= 0 else 9999,
))
# Build plan debug info
plan_info = None
if selected_plan:
plan_info = PlanDebugInfo(
goal_name=selected_plan.goal.name,
actions=[a.name for a in selected_plan.actions],
total_cost=selected_plan.total_cost,
plan_length=len(selected_plan.actions),
)
# Determine selected action and reason
selected_action = None
reason = "No plan found"
if is_night:
selected_action = "Sleep"
reason = "Night time: sleeping"
elif selected_plan and selected_plan.first_action:
selected_action = selected_plan.first_action.name
reason = f"{selected_plan.goal.name}: {selected_action}"
else:
# Fallback to reactive planning
reactive_planner = ReactivePlanner()
best_action = reactive_planner.select_best_action(state, all_goals, all_actions)
if best_action:
selected_action = best_action.name
reason = f"Reactive: {best_action.name}"
# Mark the reactive action in the action list
for action_info in action_infos:
if action_info.name == best_action.name:
action_info.is_in_plan = True
action_info.plan_order = 0
return GOAPDebugInfo(
agent_id=agent.id,
agent_name=agent.name,
world_state=state.to_dict(),
goals=goal_infos,
actions=action_infos,
current_plan=plan_info,
selected_action=selected_action,
decision_reason=reason,
)
def get_all_agents_goap_debug(
agents: list["Agent"],
market: "OrderBook",
step_in_day: int = 1,
day_steps: int = 10,
is_night: bool = False,
) -> list[GOAPDebugInfo]:
"""Get GOAP debug info for all agents."""
return [
get_goap_debug_info(agent, market, step_in_day, day_steps, is_night)
for agent in agents
if agent.is_alive()
]

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

@ -0,0 +1,185 @@
"""Goal definitions for GOAP planning.
Goals represent what an agent wants to achieve. Each goal has:
- A name/type for identification
- A condition that checks if the goal is satisfied
- A priority function that determines how important the goal is
- Optional target state values for the planner
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Optional
from .world_state import WorldState
class GoalType(Enum):
"""Types of goals agents can pursue."""
# Survival goals - highest priority when needed
SATISFY_THIRST = "satisfy_thirst"
SATISFY_HUNGER = "satisfy_hunger"
MAINTAIN_HEAT = "maintain_heat"
RESTORE_ENERGY = "restore_energy"
# Resource goals - medium priority
STOCK_WATER = "stock_water"
STOCK_FOOD = "stock_food"
STOCK_WOOD = "stock_wood"
GET_CLOTHES = "get_clothes"
# Economic goals - lower priority but persistent
BUILD_WEALTH = "build_wealth"
SELL_EXCESS = "sell_excess"
FIND_DEALS = "find_deals"
TRADER_ARBITRAGE = "trader_arbitrage"
# Night behavior
SLEEP = "sleep"
@dataclass
class Goal:
"""A goal that an agent can pursue.
Goals are the driving force of GOAP. The planner searches for
action sequences that transform the current world state into
one where the goal condition is satisfied.
Attributes:
goal_type: The type of goal (for identification)
name: Human-readable name
is_satisfied: Function that checks if goal is achieved in a state
get_priority: Function that calculates goal priority (higher = more important)
target_state: Optional dict of state values the goal aims for
max_plan_depth: Maximum actions to plan for this goal
"""
goal_type: GoalType
name: str
is_satisfied: Callable[[WorldState], bool]
get_priority: Callable[[WorldState], float]
target_state: dict = field(default_factory=dict)
max_plan_depth: int = 3
def satisfied(self, state: WorldState) -> bool:
"""Check if goal is satisfied in the given state."""
return self.is_satisfied(state)
def priority(self, state: WorldState) -> float:
"""Get the priority of this goal in the given state."""
return self.get_priority(state)
def __repr__(self) -> str:
return f"Goal({self.name})"
def create_survival_goal(
goal_type: GoalType,
name: str,
stat_name: str,
target_pct: float = 0.6,
base_priority: float = 10.0,
) -> Goal:
"""Factory for creating survival-related goals.
Survival goals have high priority when the relevant stat is low.
Priority scales with urgency.
"""
urgency_name = f"{stat_name}_urgency"
pct_name = f"{stat_name}_pct"
def is_satisfied(state: WorldState) -> bool:
return getattr(state, pct_name) >= target_pct
def get_priority(state: WorldState) -> float:
urgency = getattr(state, urgency_name)
pct = getattr(state, pct_name)
if urgency <= 0:
return 0.0 # No need to pursue this goal
# Priority increases with urgency
# Critical urgency (>1.0) gives very high priority
priority = base_priority * urgency
# Extra boost when critical
if pct < state.critical_threshold:
priority *= 2.0
return priority
return Goal(
goal_type=goal_type,
name=name,
is_satisfied=is_satisfied,
get_priority=get_priority,
target_state={pct_name: target_pct},
max_plan_depth=2, # Survival should be quick
)
def create_resource_stock_goal(
goal_type: GoalType,
name: str,
resource_name: str,
target_count: int,
base_priority: float = 5.0,
) -> Goal:
"""Factory for creating resource stockpiling goals.
Resource goals have moderate priority and aim to maintain reserves.
"""
count_name = f"{resource_name}_count"
def is_satisfied(state: WorldState) -> bool:
return getattr(state, count_name) >= target_count
def get_priority(state: WorldState) -> float:
current = getattr(state, count_name)
if current >= target_count:
return 0.0 # Already have enough
# Priority based on how far from target
deficit = target_count - current
priority = base_priority * (deficit / target_count)
# Lower priority if survival is urgent
max_urgency = max(state.thirst_urgency, state.hunger_urgency, state.heat_urgency)
if max_urgency > 0.5:
priority *= 0.5
# Hoarders prioritize stockpiling more
priority *= (0.8 + state.hoarding_rate * 0.4)
# Evening boost - stock up before night
if state.is_evening:
priority *= 1.5
return priority
return Goal(
goal_type=goal_type,
name=name,
is_satisfied=is_satisfied,
get_priority=get_priority,
target_state={count_name: target_count},
max_plan_depth=3,
)
def create_economic_goal(
goal_type: GoalType,
name: str,
is_satisfied: Callable[[WorldState], bool],
get_priority: Callable[[WorldState], float],
) -> Goal:
"""Factory for creating economic/trading goals."""
return Goal(
goal_type=goal_type,
name=name,
is_satisfied=is_satisfied,
get_priority=get_priority,
max_plan_depth=2,
)

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

@ -0,0 +1,411 @@
"""Predefined goals for GOAP agents.
Goals are organized into categories:
- Survival goals: Immediate needs (thirst, hunger, heat, energy)
- Resource goals: Building reserves
- Economic goals: Trading and wealth building
"""
from .goal import Goal, GoalType, create_survival_goal, create_resource_stock_goal, create_economic_goal
from .world_state import WorldState
# =============================================================================
# SURVIVAL GOALS
# =============================================================================
def _create_satisfy_thirst_goal() -> Goal:
"""Satisfy immediate thirst."""
return create_survival_goal(
goal_type=GoalType.SATISFY_THIRST,
name="Satisfy Thirst",
stat_name="thirst",
target_pct=0.5, # Want to get to 50%
base_priority=15.0, # Highest base priority - thirst is most dangerous
)
def _create_satisfy_hunger_goal() -> Goal:
"""Satisfy immediate hunger."""
return create_survival_goal(
goal_type=GoalType.SATISFY_HUNGER,
name="Satisfy Hunger",
stat_name="hunger",
target_pct=0.5,
base_priority=12.0,
)
def _create_maintain_heat_goal() -> Goal:
"""Maintain body heat."""
return create_survival_goal(
goal_type=GoalType.MAINTAIN_HEAT,
name="Maintain Heat",
stat_name="heat",
target_pct=0.5,
base_priority=10.0,
)
def _create_restore_energy_goal() -> Goal:
"""Restore energy when low."""
def is_satisfied(state: WorldState) -> bool:
return state.energy_pct >= 0.4
def get_priority(state: WorldState) -> float:
if state.energy_pct >= 0.4:
return 0.0
# Priority increases as energy decreases
urgency = (0.4 - state.energy_pct) / 0.4
# But not if we have more urgent survival needs
max_vital_urgency = max(state.thirst_urgency, state.hunger_urgency, state.heat_urgency)
if max_vital_urgency > 1.5:
# Critical survival need - don't rest
return 0.0
base_priority = 6.0 * urgency
# Evening boost - want energy for night
if state.is_evening:
base_priority *= 1.5
return base_priority
return Goal(
goal_type=GoalType.RESTORE_ENERGY,
name="Restore Energy",
is_satisfied=is_satisfied,
get_priority=get_priority,
target_state={"energy_pct": 0.6},
max_plan_depth=1, # Just rest
)
SURVIVAL_GOALS = [
_create_satisfy_thirst_goal(),
_create_satisfy_hunger_goal(),
_create_maintain_heat_goal(),
_create_restore_energy_goal(),
]
# =============================================================================
# RESOURCE GOALS
# =============================================================================
def _create_stock_water_goal() -> Goal:
"""Maintain water reserves."""
def is_satisfied(state: WorldState) -> bool:
target = int(2 * (0.5 + state.hoarding_rate))
return state.water_count >= target
def get_priority(state: WorldState) -> float:
target = int(2 * (0.5 + state.hoarding_rate))
if state.water_count >= target:
return 0.0
deficit = target - state.water_count
base_priority = 4.0 * (deficit / max(1, target))
# Lower if urgent survival needs
if max(state.thirst_urgency, state.hunger_urgency) > 1.0:
base_priority *= 0.3
# Evening boost
if state.is_evening:
base_priority *= 2.0
return base_priority
return Goal(
goal_type=GoalType.STOCK_WATER,
name="Stock Water",
is_satisfied=is_satisfied,
get_priority=get_priority,
max_plan_depth=2,
)
def _create_stock_food_goal() -> Goal:
"""Maintain food reserves (meat + berries)."""
def is_satisfied(state: WorldState) -> bool:
target = int(3 * (0.5 + state.hoarding_rate))
return state.food_count >= target
def get_priority(state: WorldState) -> float:
target = int(3 * (0.5 + state.hoarding_rate))
if state.food_count >= target:
return 0.0
deficit = target - state.food_count
base_priority = 4.0 * (deficit / max(1, target))
# Lower if urgent survival needs
if max(state.thirst_urgency, state.hunger_urgency) > 1.0:
base_priority *= 0.3
# Evening boost
if state.is_evening:
base_priority *= 2.0
# Risk-takers may prefer hunting (more food per action)
base_priority *= (0.8 + state.risk_tolerance * 0.4)
return base_priority
return Goal(
goal_type=GoalType.STOCK_FOOD,
name="Stock Food",
is_satisfied=is_satisfied,
get_priority=get_priority,
max_plan_depth=2,
)
def _create_stock_wood_goal() -> Goal:
"""Maintain wood reserves for fires."""
def is_satisfied(state: WorldState) -> bool:
target = int(2 * (0.5 + state.hoarding_rate))
return state.wood_count >= target
def get_priority(state: WorldState) -> float:
target = int(2 * (0.5 + state.hoarding_rate))
if state.wood_count >= target:
return 0.0
deficit = target - state.wood_count
base_priority = 3.0 * (deficit / max(1, target))
# Higher priority if heat is becoming an issue
if state.heat_urgency > 0.5:
base_priority *= 1.5
# Lower if urgent survival needs
if max(state.thirst_urgency, state.hunger_urgency) > 1.0:
base_priority *= 0.3
return base_priority
return Goal(
goal_type=GoalType.STOCK_WOOD,
name="Stock Wood",
is_satisfied=is_satisfied,
get_priority=get_priority,
max_plan_depth=2,
)
def _create_get_clothes_goal() -> Goal:
"""Get clothes for heat protection."""
def is_satisfied(state: WorldState) -> bool:
return state.has_clothes
def get_priority(state: WorldState) -> float:
if state.has_clothes:
return 0.0
# Only pursue if we have hide
if state.hide_count < 1:
return 0.0
base_priority = 2.0
# Higher if heat is an issue
if state.heat_urgency > 0.3:
base_priority *= 1.5
return base_priority
return Goal(
goal_type=GoalType.GET_CLOTHES,
name="Get Clothes",
is_satisfied=is_satisfied,
get_priority=get_priority,
max_plan_depth=1,
)
RESOURCE_GOALS = [
_create_stock_water_goal(),
_create_stock_food_goal(),
_create_stock_wood_goal(),
_create_get_clothes_goal(),
]
# =============================================================================
# ECONOMIC GOALS
# =============================================================================
def _create_build_wealth_goal() -> Goal:
"""Accumulate money through trading."""
def is_satisfied(state: WorldState) -> bool:
return state.is_wealthy
def get_priority(state: WorldState) -> float:
if state.is_wealthy:
return 0.0
# Base priority scaled by wealth desire
base_priority = 2.0 * state.wealth_desire
# Only when survival is stable
max_urgency = max(state.thirst_urgency, state.hunger_urgency, state.heat_urgency)
if max_urgency > 0.5:
return 0.0
# Traders prioritize wealth more
if state.is_trader:
base_priority *= 2.0
return base_priority
return create_economic_goal(
goal_type=GoalType.BUILD_WEALTH,
name="Build Wealth",
is_satisfied=is_satisfied,
get_priority=get_priority,
)
def _create_sell_excess_goal() -> Goal:
"""Sell excess resources on the market."""
def is_satisfied(state: WorldState) -> bool:
# Satisfied if inventory is not getting full
return state.inventory_space > 3
def get_priority(state: WorldState) -> float:
if state.inventory_space > 5:
return 0.0 # Plenty of space
# Priority increases as inventory fills
fullness = 1.0 - (state.inventory_space / 12.0)
base_priority = 3.0 * fullness
# Low hoarders sell more readily
base_priority *= (1.5 - state.hoarding_rate)
# Only when survival is stable
max_urgency = max(state.thirst_urgency, state.hunger_urgency)
if max_urgency > 0.5:
base_priority *= 0.5
return base_priority
return create_economic_goal(
goal_type=GoalType.SELL_EXCESS,
name="Sell Excess",
is_satisfied=is_satisfied,
get_priority=get_priority,
)
def _create_find_deals_goal() -> Goal:
"""Find good deals on the market."""
def is_satisfied(state: WorldState) -> bool:
# This goal is never fully "satisfied" - it's opportunistic
return False
def get_priority(state: WorldState) -> float:
# Only pursue if we have money and market access
if state.money < 10:
return 0.0
# Check if there are deals available
has_deals = state.can_buy_water or state.can_buy_food or state.can_buy_wood
if not has_deals:
return 0.0
# Base priority from market affinity
base_priority = 2.0 * state.market_affinity
# Only when survival is stable
max_urgency = max(state.thirst_urgency, state.hunger_urgency)
if max_urgency > 0.5:
return 0.0
# Need inventory space
if state.inventory_space < 2:
return 0.0
return base_priority
return create_economic_goal(
goal_type=GoalType.FIND_DEALS,
name="Find Deals",
is_satisfied=is_satisfied,
get_priority=get_priority,
)
def _create_trader_arbitrage_goal() -> Goal:
"""Trader-specific arbitrage goal (buy low, sell high)."""
def is_satisfied(state: WorldState) -> bool:
return False # Always looking for opportunities
def get_priority(state: WorldState) -> float:
# Only for traders
if not state.is_trader:
return 0.0
# Need capital to trade
if state.money < 20:
return 1.0 # Low priority - need to sell something first
# Base priority for traders
base_priority = 5.0
# Only when survival is stable
max_urgency = max(state.thirst_urgency, state.hunger_urgency, state.heat_urgency)
if max_urgency > 0.3:
base_priority *= 0.5
return base_priority
return create_economic_goal(
goal_type=GoalType.TRADER_ARBITRAGE,
name="Trader Arbitrage",
is_satisfied=is_satisfied,
get_priority=get_priority,
)
def _create_sleep_goal() -> Goal:
"""Sleep at night."""
def is_satisfied(state: WorldState) -> bool:
return not state.is_night # Satisfied when it's not night
def get_priority(state: WorldState) -> float:
if not state.is_night:
return 0.0
# Highest priority at night
return 100.0
return Goal(
goal_type=GoalType.SLEEP,
name="Sleep",
is_satisfied=is_satisfied,
get_priority=get_priority,
max_plan_depth=1,
)
ECONOMIC_GOALS = [
_create_build_wealth_goal(),
_create_sell_excess_goal(),
_create_find_deals_goal(),
_create_trader_arbitrage_goal(),
_create_sleep_goal(),
]
def get_all_goals() -> list[Goal]:
"""Get all available goals."""
return SURVIVAL_GOALS + RESOURCE_GOALS + ECONOMIC_GOALS

View File

@ -0,0 +1,411 @@
"""GOAP-based AI decision system for agents.
This module provides the main interface for GOAP-based decision making.
It replaces the priority-based AgentAI with a goal-oriented planner.
"""
from dataclasses import dataclass, field
from typing import Optional, TYPE_CHECKING
from backend.domain.action import ActionType
from backend.domain.resources import ResourceType
from backend.domain.personality import get_trade_price_modifier
from .world_state import WorldState, create_world_state
from .goal import Goal
from .action import GOAPAction
from .planner import GOAPPlanner, ReactivePlanner, Plan
from .goals import get_all_goals
from .actions import get_all_actions
if TYPE_CHECKING:
from backend.domain.agent import Agent
from backend.core.market import OrderBook
from backend.core.ai import AIDecision, TradeItem
@dataclass
class TradeItem:
"""A single item to buy/sell in a trade."""
order_id: str
resource_type: ResourceType
quantity: int
price_per_unit: int
@dataclass
class AIDecision:
"""A decision made by the AI for an agent."""
action: ActionType
target_resource: Optional[ResourceType] = None
order_id: Optional[str] = None
quantity: int = 1
price: int = 0
reason: str = ""
trade_items: list[TradeItem] = field(default_factory=list)
adjust_order_id: Optional[str] = None
new_price: Optional[int] = None
# GOAP-specific fields
goal_name: str = ""
plan_length: int = 0
def to_dict(self) -> dict:
return {
"action": self.action.value,
"target_resource": self.target_resource.value if self.target_resource else None,
"order_id": self.order_id,
"quantity": self.quantity,
"price": self.price,
"reason": self.reason,
"trade_items": [
{
"order_id": t.order_id,
"resource_type": t.resource_type.value,
"quantity": t.quantity,
"price_per_unit": t.price_per_unit,
}
for t in self.trade_items
],
"adjust_order_id": self.adjust_order_id,
"new_price": self.new_price,
"goal_name": self.goal_name,
"plan_length": self.plan_length,
}
class GOAPAgentAI:
"""GOAP-based AI decision maker for agents.
This uses goal-oriented action planning to select actions:
1. Build world state from agent and market
2. Evaluate all goals and their priorities
3. Use planner to find action sequence for best goal
4. Return the first action as the decision
Falls back to reactive planning for simple decisions.
"""
def __init__(
self,
agent: "Agent",
market: "OrderBook",
step_in_day: int = 1,
day_steps: int = 10,
current_turn: int = 0,
is_night: bool = False,
):
self.agent = agent
self.market = market
self.step_in_day = step_in_day
self.day_steps = day_steps
self.current_turn = current_turn
self.is_night = is_night
# Build world state
self.state = create_world_state(
agent=agent,
market=market,
step_in_day=step_in_day,
day_steps=day_steps,
is_night=is_night,
)
# Initialize planners
self.planner = GOAPPlanner(max_iterations=50)
self.reactive_planner = ReactivePlanner()
# Get available goals and actions
self.goals = get_all_goals()
self.actions = get_all_actions()
# Personality shortcuts
self.p = agent.personality
self.skills = agent.skills
def decide(self) -> AIDecision:
"""Make a decision using GOAP planning.
Decision flow:
1. Force sleep if night
2. Try to find a plan for the highest priority goal
3. If no plan found, use reactive selection
4. Convert GOAP action to AIDecision with proper parameters
"""
# Night time - mandatory sleep
if self.is_night:
return AIDecision(
action=ActionType.SLEEP,
reason="Night time: sleeping",
goal_name="Sleep",
)
# Try GOAP planning
plan = self.planner.plan_for_goals(
initial_state=self.state,
goals=self.goals,
available_actions=self.actions,
)
if plan and not plan.is_empty:
# We have a plan - execute first action
goap_action = plan.first_action
return self._convert_to_decision(
goap_action=goap_action,
goal=plan.goal,
plan=plan,
)
# Fallback to reactive selection
best_action = self.reactive_planner.select_best_action(
state=self.state,
goals=self.goals,
available_actions=self.actions,
)
if best_action:
return self._convert_to_decision(
goap_action=best_action,
goal=None,
plan=None,
)
# Ultimate fallback - rest
return AIDecision(
action=ActionType.REST,
reason="No valid action found, resting",
)
def _convert_to_decision(
self,
goap_action: GOAPAction,
goal: Optional[Goal],
plan: Optional[Plan],
) -> AIDecision:
"""Convert a GOAP action to an AIDecision with proper parameters.
This handles the translation from abstract GOAP actions to
concrete decisions with order IDs, prices, etc.
"""
action_type = goap_action.action_type
target_resource = goap_action.target_resource
# Build reason string
if goal:
reason = f"{goal.name}: {goap_action.name}"
else:
reason = f"Reactive: {goap_action.name}"
# Handle different action types
if action_type == ActionType.CONSUME:
return AIDecision(
action=action_type,
target_resource=target_resource,
reason=reason,
goal_name=goal.name if goal else "",
plan_length=len(plan.actions) if plan else 0,
)
elif action_type == ActionType.TRADE:
return self._create_trade_decision(goap_action, goal, plan, reason)
elif action_type in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
ActionType.GET_WATER, ActionType.WEAVE]:
return AIDecision(
action=action_type,
target_resource=target_resource,
reason=reason,
goal_name=goal.name if goal else "",
plan_length=len(plan.actions) if plan else 0,
)
elif action_type == ActionType.BUILD_FIRE:
return AIDecision(
action=action_type,
target_resource=ResourceType.WOOD,
reason=reason,
goal_name=goal.name if goal else "",
plan_length=len(plan.actions) if plan else 0,
)
elif action_type in [ActionType.REST, ActionType.SLEEP]:
return AIDecision(
action=action_type,
reason=reason,
goal_name=goal.name if goal else "",
plan_length=len(plan.actions) if plan else 0,
)
# Default case
return AIDecision(
action=action_type,
target_resource=target_resource,
reason=reason,
goal_name=goal.name if goal else "",
plan_length=len(plan.actions) if plan else 0,
)
def _create_trade_decision(
self,
goap_action: GOAPAction,
goal: Optional[Goal],
plan: Optional[Plan],
reason: str,
) -> AIDecision:
"""Create a trade decision with actual market parameters.
This translates abstract "Buy X" or "Sell X" actions into
concrete decisions with order IDs, prices, and quantities.
"""
target_resource = goap_action.target_resource
action_name = goap_action.name.lower()
if "buy" in action_name:
# Find the best order to buy from
order = self.market.get_cheapest_order(target_resource)
if order and order.seller_id != self.agent.id:
# Calculate quantity to buy
can_afford = self.agent.money // max(1, order.price_per_unit)
space = self.agent.inventory_space()
quantity = min(2, can_afford, space, order.quantity)
if quantity > 0:
return AIDecision(
action=ActionType.TRADE,
target_resource=target_resource,
order_id=order.id,
quantity=quantity,
price=order.price_per_unit,
reason=f"{reason} @ {order.price_per_unit}c",
goal_name=goal.name if goal else "",
plan_length=len(plan.actions) if plan else 0,
)
# Can't buy - fallback to gathering
return self._create_gather_fallback(target_resource, reason, goal, plan)
elif "sell" in action_name:
# Create a sell order
quantity_available = self.agent.get_resource_count(target_resource)
# Calculate minimum to keep
min_keep = self._get_min_keep(target_resource)
quantity_to_sell = min(3, quantity_available - min_keep)
if quantity_to_sell > 0:
price = self._calculate_sell_price(target_resource)
return AIDecision(
action=ActionType.TRADE,
target_resource=target_resource,
quantity=quantity_to_sell,
price=price,
reason=f"{reason} @ {price}c",
goal_name=goal.name if goal else "",
plan_length=len(plan.actions) if plan else 0,
)
# Invalid trade action - rest
return AIDecision(
action=ActionType.REST,
reason="Trade not possible",
)
def _create_gather_fallback(
self,
resource_type: ResourceType,
reason: str,
goal: Optional[Goal],
plan: Optional[Plan],
) -> AIDecision:
"""Create a gather action as fallback when buying isn't possible."""
# Map resource to gather action
action_map = {
ResourceType.WATER: ActionType.GET_WATER,
ResourceType.BERRIES: ActionType.GATHER,
ResourceType.MEAT: ActionType.HUNT,
ResourceType.WOOD: ActionType.CHOP_WOOD,
}
action = action_map.get(resource_type, ActionType.GATHER)
return AIDecision(
action=action,
target_resource=resource_type,
reason=f"{reason} (gathering instead)",
goal_name=goal.name if goal else "",
plan_length=len(plan.actions) if plan else 0,
)
def _get_min_keep(self, resource_type: ResourceType) -> int:
"""Get minimum quantity to keep for survival."""
# Adjusted by hoarding rate
hoarding_mult = 0.5 + self.p.hoarding_rate
base_min = {
ResourceType.WATER: 2,
ResourceType.MEAT: 1,
ResourceType.BERRIES: 2,
ResourceType.WOOD: 1,
ResourceType.HIDE: 0,
}
return int(base_min.get(resource_type, 1) * hoarding_mult)
def _calculate_sell_price(self, resource_type: ResourceType) -> int:
"""Calculate sell price based on fair value and market conditions."""
# Get energy cost to produce
from backend.core.ai import get_energy_cost
from backend.config import get_config
config = get_config()
economy = getattr(config, 'economy', None)
energy_to_money_ratio = getattr(economy, 'energy_to_money_ratio', 150) if economy else 150
min_price = getattr(economy, 'min_price', 100) if economy else 100
energy_cost = get_energy_cost(resource_type)
fair_value = max(min_price, int(energy_cost * energy_to_money_ratio))
# Apply trading skill
sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False)
# Get market signal
signal = self.market.get_market_signal(resource_type)
if signal == "sell": # Scarcity
price = int(fair_value * 1.3 * sell_modifier)
elif signal == "hold":
price = int(fair_value * sell_modifier)
else: # Surplus
cheapest = self.market.get_cheapest_order(resource_type)
if cheapest and cheapest.seller_id != self.agent.id:
price = max(min_price, cheapest.price_per_unit - 1)
else:
price = int(fair_value * 0.8 * sell_modifier)
return max(min_price, price)
def get_goap_decision(
agent: "Agent",
market: "OrderBook",
step_in_day: int = 1,
day_steps: int = 10,
current_turn: int = 0,
is_night: bool = False,
) -> AIDecision:
"""Convenience function to get a GOAP-based AI decision for an agent.
This is the main entry point for the GOAP AI system.
"""
ai = GOAPAgentAI(
agent=agent,
market=market,
step_in_day=step_in_day,
day_steps=day_steps,
current_turn=current_turn,
is_night=is_night,
)
return ai.decide()

View File

@ -0,0 +1,335 @@
"""GOAP Planner using A* search.
The planner finds optimal action sequences to achieve goals.
It uses A* search with the goal condition as the target.
"""
import heapq
from dataclasses import dataclass, field
from typing import Optional
from .world_state import WorldState
from .goal import Goal
from .action import GOAPAction
@dataclass(order=True)
class PlanNode:
"""A node in the planning search tree."""
f_cost: float # Total cost (g + h)
g_cost: float = field(compare=False) # Cost so far
state: WorldState = field(compare=False)
action: Optional[GOAPAction] = field(compare=False, default=None)
parent: Optional["PlanNode"] = field(compare=False, default=None)
depth: int = field(compare=False, default=0)
@dataclass
class Plan:
"""A plan is a sequence of actions to achieve a goal."""
goal: Goal
actions: list[GOAPAction]
total_cost: float
expected_final_state: WorldState
@property
def first_action(self) -> Optional[GOAPAction]:
"""Get the first action to execute."""
return self.actions[0] if self.actions else None
@property
def is_empty(self) -> bool:
return len(self.actions) == 0
def __repr__(self) -> str:
action_names = " -> ".join(a.name for a in self.actions)
return f"Plan({self.goal.name}: {action_names} [cost={self.total_cost:.1f}])"
class GOAPPlanner:
"""A* planner for GOAP.
Finds the lowest-cost sequence of actions that transforms
the current world state into one where the goal is satisfied.
"""
def __init__(self, max_iterations: int = 100):
self.max_iterations = max_iterations
def plan(
self,
initial_state: WorldState,
goal: Goal,
available_actions: list[GOAPAction],
) -> Optional[Plan]:
"""Find an action sequence to achieve the goal.
Uses A* search where:
- g(n) = accumulated action costs
- h(n) = heuristic estimate to goal (0 for now - effectively Dijkstra's)
Returns None if no plan found within iteration limit.
"""
# Check if goal is already satisfied
if goal.satisfied(initial_state):
return Plan(
goal=goal,
actions=[],
total_cost=0.0,
expected_final_state=initial_state,
)
# Priority queue for A*
open_set: list[PlanNode] = []
start_node = PlanNode(
f_cost=0.0,
g_cost=0.0,
state=initial_state,
action=None,
parent=None,
depth=0,
)
heapq.heappush(open_set, start_node)
# Track visited states to avoid cycles
# We use a simplified state hash for efficiency
visited: set[tuple] = set()
iterations = 0
while open_set and iterations < self.max_iterations:
iterations += 1
# Get node with lowest f_cost
current = heapq.heappop(open_set)
# Check depth limit
if current.depth >= goal.max_plan_depth:
continue
# Create state hash for cycle detection
state_hash = self._hash_state(current.state)
if state_hash in visited:
continue
visited.add(state_hash)
# Try each action
for action in available_actions:
# Check if action is valid in current state
if not action.is_valid(current.state):
continue
# Apply action to get new state
new_state = action.apply(current.state)
# Calculate costs
action_cost = action.get_cost(current.state)
g_cost = current.g_cost + action_cost
h_cost = self._heuristic(new_state, goal)
f_cost = g_cost + h_cost
# Create new node
new_node = PlanNode(
f_cost=f_cost,
g_cost=g_cost,
state=new_state,
action=action,
parent=current,
depth=current.depth + 1,
)
# Check if goal is satisfied
if goal.satisfied(new_state):
# Reconstruct and return plan
return self._reconstruct_plan(new_node, goal)
# Add to open set
heapq.heappush(open_set, new_node)
# No plan found
return None
def plan_for_goals(
self,
initial_state: WorldState,
goals: list[Goal],
available_actions: list[GOAPAction],
) -> Optional[Plan]:
"""Find the best plan among multiple goals.
Selects the highest-priority goal that has a valid plan,
considering both goal priority and plan cost.
"""
# Sort goals by priority (highest first)
sorted_goals = sorted(goals, key=lambda g: g.priority(initial_state), reverse=True)
best_plan: Optional[Plan] = None
best_score = float('-inf')
for goal in sorted_goals:
priority = goal.priority(initial_state)
# Skip low-priority goals if we already have a good plan
if priority <= 0:
continue
if best_plan and priority < best_score * 0.5:
# This goal is much lower priority, skip
break
plan = self.plan(initial_state, goal, available_actions)
if plan:
# Score = priority / (cost + 1)
# Higher priority and lower cost = better
score = priority / (plan.total_cost + 1.0)
if score > best_score:
best_score = score
best_plan = plan
return best_plan
def _hash_state(self, state: WorldState) -> tuple:
"""Create a hashable representation of key state values.
We don't hash everything - just the values that matter for planning.
"""
return (
round(state.thirst_pct, 1),
round(state.hunger_pct, 1),
round(state.heat_pct, 1),
round(state.energy_pct, 1),
state.water_count,
state.food_count,
state.wood_count,
state.money // 10, # Bucket money
)
def _heuristic(self, state: WorldState, goal: Goal) -> float:
"""Estimate cost to reach goal from state.
For now, we use a simple heuristic based on the distance
from current state values to goal target values.
"""
if not goal.target_state:
return 0.0
h = 0.0
for key, target in goal.target_state.items():
if hasattr(state, key):
current = getattr(state, key)
if isinstance(current, (int, float)) and isinstance(target, (int, float)):
diff = abs(target - current)
h += diff
return h
def _reconstruct_plan(self, final_node: PlanNode, goal: Goal) -> Plan:
"""Reconstruct the action sequence from the final node."""
actions = []
node = final_node
while node.parent is not None:
if node.action:
actions.append(node.action)
node = node.parent
actions.reverse()
return Plan(
goal=goal,
actions=actions,
total_cost=final_node.g_cost,
expected_final_state=final_node.state,
)
class ReactivePlanner:
"""A simpler reactive planner for immediate needs.
Sometimes we don't need full planning - we just need to
pick the best immediate action. This planner evaluates
single actions against goals.
"""
def select_best_action(
self,
state: WorldState,
goals: list[Goal],
available_actions: list[GOAPAction],
) -> Optional[GOAPAction]:
"""Select the single best action to take right now.
Evaluates each valid action and scores it based on how well
it progresses toward high-priority goals.
"""
best_action: Optional[GOAPAction] = None
best_score = float('-inf')
for action in available_actions:
if not action.is_valid(state):
continue
score = self._score_action(state, action, goals)
if score > best_score:
best_score = score
best_action = action
return best_action
def _score_action(
self,
state: WorldState,
action: GOAPAction,
goals: list[Goal],
) -> float:
"""Score an action based on its contribution to goals."""
# Apply action to get expected new state
new_state = action.apply(state)
action_cost = action.get_cost(state)
total_score = 0.0
for goal in goals:
priority = goal.priority(state)
if priority <= 0:
continue
# Check if this action helps with the goal
was_satisfied = goal.satisfied(state)
now_satisfied = goal.satisfied(new_state)
if now_satisfied and not was_satisfied:
# Action satisfies the goal - big bonus!
total_score += priority * 10.0
elif not was_satisfied:
# Check if we made progress
# This is a simplified check based on urgencies
old_urgency = self._get_goal_urgency(goal, state)
new_urgency = self._get_goal_urgency(goal, new_state)
if new_urgency < old_urgency:
improvement = old_urgency - new_urgency
total_score += priority * improvement * 5.0
# Subtract cost
total_score -= action_cost
return total_score
def _get_goal_urgency(self, goal: Goal, state: WorldState) -> float:
"""Get the urgency related to a goal."""
# Map goal types to state urgencies
from .goal import GoalType
urgency_map = {
GoalType.SATISFY_THIRST: state.thirst_urgency,
GoalType.SATISFY_HUNGER: state.hunger_urgency,
GoalType.MAINTAIN_HEAT: state.heat_urgency,
GoalType.RESTORE_ENERGY: state.energy_urgency,
}
return urgency_map.get(goal.goal_type, 0.0)

View File

@ -0,0 +1,319 @@
"""World State representation for GOAP planning.
The WorldState is a symbolic representation of the agent's current situation,
used by the planner to reason about actions and goals.
"""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from backend.domain.agent import Agent
from backend.core.market import OrderBook
@dataclass
class WorldState:
"""Symbolic representation of the world from an agent's perspective.
This captures all relevant state needed for GOAP planning:
- Agent vital stats (as percentages 0-1)
- Resource counts in inventory
- Market availability
- Economic state
- Time of day
The state uses normalized values (0-1) for stats to make
threshold comparisons easy and consistent.
"""
# Vital stats as percentages (0.0 to 1.0)
thirst_pct: float = 1.0
hunger_pct: float = 1.0
heat_pct: float = 1.0
energy_pct: float = 1.0
# Resource counts in inventory
water_count: int = 0
food_count: int = 0 # meat + berries
meat_count: int = 0
berries_count: int = 0
wood_count: int = 0
hide_count: int = 0
# Inventory state
has_clothes: bool = False
inventory_space: int = 0
inventory_full: bool = False
# Economic state
money: int = 0
is_wealthy: bool = False # Has comfortable money reserves
# Market availability (can we buy these?)
can_buy_water: bool = False
can_buy_food: bool = False
can_buy_meat: bool = False
can_buy_berries: bool = False
can_buy_wood: bool = False
water_market_price: int = 0
food_market_price: int = 0 # Cheapest of meat/berries
wood_market_price: int = 0
# Time state
is_night: bool = False
is_evening: bool = False # Near end of day
step_in_day: int = 0
day_steps: int = 10
# Agent personality shortcuts (affect goal priorities)
wealth_desire: float = 0.5
hoarding_rate: float = 0.5
risk_tolerance: float = 0.5
market_affinity: float = 0.5
is_trader: bool = False
# Profession preferences (0.5-1.5 range, higher = more preferred)
gather_preference: float = 1.0
hunt_preference: float = 1.0
trade_preference: float = 1.0
# Skill levels (0.0-1.0, higher = more skilled)
hunting_skill: float = 0.0
gathering_skill: float = 0.0
trading_skill: float = 0.0
# Critical thresholds (from config)
critical_threshold: float = 0.25
low_threshold: float = 0.45
# Calculated urgencies (how urgent is each need?)
thirst_urgency: float = 0.0
hunger_urgency: float = 0.0
heat_urgency: float = 0.0
energy_urgency: float = 0.0
def __post_init__(self):
"""Calculate urgencies after initialization."""
self._calculate_urgencies()
def _calculate_urgencies(self):
"""Calculate urgency values for each vital stat.
Urgency is 0 when stat is full, and increases as stat decreases.
Urgency > 1.0 when in critical range.
"""
# Urgency increases as stat decreases
# 0.0 = no urgency, 1.0 = needs attention, 2.0+ = critical
def calc_urgency(pct: float, critical: float, low: float) -> float:
if pct >= low:
return 0.0
elif pct >= critical:
# Linear increase from 0 to 1 as we go from low to critical
return 1.0 - (pct - critical) / (low - critical)
else:
# Exponential increase below critical
return 1.0 + (critical - pct) / critical * 2.0
self.thirst_urgency = calc_urgency(self.thirst_pct, self.critical_threshold, self.low_threshold)
self.hunger_urgency = calc_urgency(self.hunger_pct, self.critical_threshold, self.low_threshold)
self.heat_urgency = calc_urgency(self.heat_pct, self.critical_threshold, self.low_threshold)
# Energy urgency is different - we care about absolute level for work
if self.energy_pct < 0.25:
self.energy_urgency = 2.0
elif self.energy_pct < 0.40:
self.energy_urgency = 1.0
else:
self.energy_urgency = 0.0
def copy(self) -> "WorldState":
"""Create a copy of this world state."""
return WorldState(
thirst_pct=self.thirst_pct,
hunger_pct=self.hunger_pct,
heat_pct=self.heat_pct,
energy_pct=self.energy_pct,
water_count=self.water_count,
food_count=self.food_count,
meat_count=self.meat_count,
berries_count=self.berries_count,
wood_count=self.wood_count,
hide_count=self.hide_count,
has_clothes=self.has_clothes,
inventory_space=self.inventory_space,
inventory_full=self.inventory_full,
money=self.money,
is_wealthy=self.is_wealthy,
can_buy_water=self.can_buy_water,
can_buy_food=self.can_buy_food,
can_buy_meat=self.can_buy_meat,
can_buy_berries=self.can_buy_berries,
can_buy_wood=self.can_buy_wood,
water_market_price=self.water_market_price,
food_market_price=self.food_market_price,
wood_market_price=self.wood_market_price,
is_night=self.is_night,
is_evening=self.is_evening,
step_in_day=self.step_in_day,
day_steps=self.day_steps,
wealth_desire=self.wealth_desire,
hoarding_rate=self.hoarding_rate,
risk_tolerance=self.risk_tolerance,
market_affinity=self.market_affinity,
is_trader=self.is_trader,
critical_threshold=self.critical_threshold,
low_threshold=self.low_threshold,
)
def to_dict(self) -> dict:
"""Convert to dictionary for debugging/logging."""
return {
"vitals": {
"thirst": round(self.thirst_pct, 2),
"hunger": round(self.hunger_pct, 2),
"heat": round(self.heat_pct, 2),
"energy": round(self.energy_pct, 2),
},
"urgencies": {
"thirst": round(self.thirst_urgency, 2),
"hunger": round(self.hunger_urgency, 2),
"heat": round(self.heat_urgency, 2),
"energy": round(self.energy_urgency, 2),
},
"inventory": {
"water": self.water_count,
"meat": self.meat_count,
"berries": self.berries_count,
"wood": self.wood_count,
"hide": self.hide_count,
"space": self.inventory_space,
},
"economy": {
"money": self.money,
"is_wealthy": self.is_wealthy,
},
"market": {
"can_buy_water": self.can_buy_water,
"can_buy_food": self.can_buy_food,
"can_buy_wood": self.can_buy_wood,
},
"time": {
"is_night": self.is_night,
"is_evening": self.is_evening,
"step": self.step_in_day,
},
}
def create_world_state(
agent: "Agent",
market: "OrderBook",
step_in_day: int = 1,
day_steps: int = 10,
is_night: bool = False,
) -> WorldState:
"""Create a WorldState from an agent and market.
This is the main factory function for creating world states.
It extracts all relevant information from the agent and market.
"""
from backend.domain.resources import ResourceType
from backend.config import get_config
config = get_config()
agent_config = config.agent_stats
economy_config = getattr(config, 'economy', None)
stats = agent.stats
# Calculate stat percentages
thirst_pct = stats.thirst / stats.MAX_THIRST
hunger_pct = stats.hunger / stats.MAX_HUNGER
heat_pct = stats.heat / stats.MAX_HEAT
energy_pct = stats.energy / stats.MAX_ENERGY
# Get resource counts
water_count = agent.get_resource_count(ResourceType.WATER)
meat_count = agent.get_resource_count(ResourceType.MEAT)
berries_count = agent.get_resource_count(ResourceType.BERRIES)
wood_count = agent.get_resource_count(ResourceType.WOOD)
hide_count = agent.get_resource_count(ResourceType.HIDE)
food_count = meat_count + berries_count
# Check market availability
def get_market_info(resource_type: ResourceType) -> tuple[bool, int]:
"""Get market availability and price for a resource."""
order = market.get_cheapest_order(resource_type)
if order and order.seller_id != agent.id and agent.money >= order.price_per_unit:
return True, order.price_per_unit
return False, 0
can_buy_water, water_price = get_market_info(ResourceType.WATER)
can_buy_meat, meat_price = get_market_info(ResourceType.MEAT)
can_buy_berries, berries_price = get_market_info(ResourceType.BERRIES)
can_buy_wood, wood_price = get_market_info(ResourceType.WOOD)
# Can buy food if we can buy either meat or berries
can_buy_food = can_buy_meat or can_buy_berries
food_price = min(
meat_price if can_buy_meat else float('inf'),
berries_price if can_buy_berries else float('inf')
)
food_price = food_price if food_price != float('inf') else 0
# Wealth calculation
min_wealth_target = getattr(economy_config, 'min_wealth_target', 50) if economy_config else 50
wealth_target = int(min_wealth_target * (0.5 + agent.personality.wealth_desire))
is_wealthy = agent.money >= wealth_target
# Trader check
is_trader = agent.personality.trade_preference > 1.3 and agent.personality.market_affinity > 0.5
# Evening check (last 2 steps before night)
is_evening = step_in_day >= day_steps - 2
return WorldState(
thirst_pct=thirst_pct,
hunger_pct=hunger_pct,
heat_pct=heat_pct,
energy_pct=energy_pct,
water_count=water_count,
food_count=food_count,
meat_count=meat_count,
berries_count=berries_count,
wood_count=wood_count,
hide_count=hide_count,
has_clothes=agent.has_clothes(),
inventory_space=agent.inventory_space(),
inventory_full=agent.inventory_full(),
money=agent.money,
is_wealthy=is_wealthy,
can_buy_water=can_buy_water,
can_buy_food=can_buy_food,
can_buy_meat=can_buy_meat,
can_buy_berries=can_buy_berries,
can_buy_wood=can_buy_wood,
water_market_price=water_price,
food_market_price=int(food_price),
wood_market_price=wood_price,
is_night=is_night,
is_evening=is_evening,
step_in_day=step_in_day,
day_steps=day_steps,
wealth_desire=agent.personality.wealth_desire,
hoarding_rate=agent.personality.hoarding_rate,
risk_tolerance=agent.personality.risk_tolerance,
market_affinity=agent.personality.market_affinity,
is_trader=is_trader,
gather_preference=agent.personality.gather_preference,
hunt_preference=agent.personality.hunt_preference,
trade_preference=agent.personality.trade_preference,
hunting_skill=agent.skills.hunting,
gathering_skill=agent.skills.gathering,
trading_skill=agent.skills.trading,
critical_threshold=agent_config.critical_threshold,
low_threshold=0.45, # Could also be in config
)

View File

@ -40,31 +40,32 @@ 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 = 1 price_per_unit: int = 100 # Default to min_price from config
created_turn: int = 0 created_turn: int = 0
status: OrderStatus = OrderStatus.ACTIVE status: OrderStatus = OrderStatus.ACTIVE
# Price adjustment tracking # Price adjustment tracking
turns_without_sale: int = 0 turns_without_sale: int = 0
original_price: int = 0 original_price: int = 0
last_adjusted_turn: int = 0 # Track when price was last changed last_adjusted_turn: int = 0 # Track when price was last changed
def __post_init__(self): def __post_init__(self):
if self.original_price == 0: if self.original_price == 0:
self.original_price = self.price_per_unit self.original_price = self.price_per_unit
if self.last_adjusted_turn == 0: if self.last_adjusted_turn == 0:
self.last_adjusted_turn = self.created_turn self.last_adjusted_turn = self.created_turn
@property @property
def total_price(self) -> int: def total_price(self) -> int:
"""Get total price for all units.""" """Get total price for all units."""
return self.quantity * self.price_per_unit return self.quantity * self.price_per_unit
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(1, self.price_per_unit - reduction) self.price_per_unit = max(min_price, self.price_per_unit - reduction)
def adjust_price(self, new_price: int, current_turn: int) -> bool: 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."""
if new_price < 1: if new_price < 1:
@ -72,11 +73,11 @@ class Order:
self.price_per_unit = new_price self.price_per_unit = new_price
self.last_adjusted_turn = current_turn self.last_adjusted_turn = current_turn
return True return True
def can_raise_price(self, current_turn: int, min_turns: int = 2) -> bool: def can_raise_price(self, current_turn: int, min_turns: int = 2) -> bool:
"""Check if enough time has passed to raise the price again.""" """Check if enough time has passed to raise the price again."""
return current_turn - self.last_adjusted_turn >= min_turns return current_turn - self.last_adjusted_turn >= min_turns
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary for API serialization.""" """Convert to dictionary for API serialization."""
return { return {
@ -104,7 +105,7 @@ class TradeResult:
quantity: int = 0 quantity: int = 0
total_paid: int = 0 total_paid: int = 0
message: str = "" message: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"success": self.success, "success": self.success,
@ -124,31 +125,39 @@ 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.
Features: Features:
- Track price history per resource type - Track price history per resource type
- Calculate supply/demand scores - Calculate supply/demand scores
- Suggest prices based on market conditions - Suggest prices based on market conditions
- Allow sellers to adjust prices dynamically - Allow sellers to adjust prices dynamically
Configuration is loaded from config.json. Configuration is loaded from config.json.
""" """
orders: list[Order] = field(default_factory=list) orders: list[Order] = field(default_factory=list)
trade_history: list[TradeResult] = field(default_factory=list) trade_history: list[TradeResult] = field(default_factory=list)
price_history: dict[ResourceType, PriceHistory] = field(default_factory=dict) price_history: dict[ResourceType, PriceHistory] = field(default_factory=dict)
# Configuration - defaults loaded from config.json in __post_init__ # Configuration - defaults loaded from config.json in __post_init__
TURNS_BEFORE_DISCOUNT: int = 15 TURNS_BEFORE_DISCOUNT: int = 15
DISCOUNT_RATE: float = 0.12 DISCOUNT_RATE: float = 0.12
# Supply/demand thresholds # Supply/demand thresholds
LOW_SUPPLY_THRESHOLD: int = 3 # Less than this = scarcity LOW_SUPPLY_THRESHOLD: int = 3 # Less than this = scarcity
HIGH_SUPPLY_THRESHOLD: int = 10 # More than this = surplus HIGH_SUPPLY_THRESHOLD: int = 10 # More than this = surplus
DEMAND_DECAY: float = 0.95 # How fast demand score decays per turn DEMAND_DECAY: float = 0.95 # How fast demand score decays per turn
def __post_init__(self): def __post_init__(self):
"""Initialize price history and load config values.""" """Initialize price history and load config values."""
# Load market config from config.json # Load market config from config.json
@ -158,11 +167,11 @@ class OrderBook:
self.DISCOUNT_RATE = cfg.discount_rate self.DISCOUNT_RATE = cfg.discount_rate
except Exception: except Exception:
pass # Use defaults if config not available pass # Use defaults if config not available
if not self.price_history: if not self.price_history:
for resource_type in ResourceType: for resource_type in ResourceType:
self.price_history[resource_type] = PriceHistory() self.price_history[resource_type] = PriceHistory()
def place_order( def place_order(
self, self,
seller_id: str, seller_id: str,
@ -181,7 +190,7 @@ class OrderBook:
) )
self.orders.append(order) self.orders.append(order)
return order return order
def cancel_order(self, order_id: str, seller_id: str) -> bool: def cancel_order(self, order_id: str, seller_id: str) -> bool:
"""Cancel an order. Returns True if successful.""" """Cancel an order. Returns True if successful."""
for order in self.orders: for order in self.orders:
@ -190,11 +199,11 @@ class OrderBook:
order.status = OrderStatus.CANCELLED order.status = OrderStatus.CANCELLED
return True return True
return False return False
def get_active_orders(self) -> list[Order]: def get_active_orders(self) -> list[Order]:
"""Get all active orders.""" """Get all active orders."""
return [o for o in self.orders if o.status == OrderStatus.ACTIVE] return [o for o in self.orders if o.status == OrderStatus.ACTIVE]
def get_orders_by_type(self, resource_type: ResourceType) -> list[Order]: def get_orders_by_type(self, resource_type: ResourceType) -> list[Order]:
"""Get all active orders for a specific resource type, sorted by price.""" """Get all active orders for a specific resource type, sorted by price."""
orders = [ orders = [
@ -202,19 +211,19 @@ class OrderBook:
if o.status == OrderStatus.ACTIVE and o.resource_type == resource_type if o.status == OrderStatus.ACTIVE and o.resource_type == resource_type
] ]
return sorted(orders, key=lambda o: o.price_per_unit) return sorted(orders, key=lambda o: o.price_per_unit)
def get_cheapest_order(self, resource_type: ResourceType) -> Optional[Order]: def get_cheapest_order(self, resource_type: ResourceType) -> Optional[Order]:
"""Get the cheapest active order for a resource type.""" """Get the cheapest active order for a resource type."""
orders = self.get_orders_by_type(resource_type) orders = self.get_orders_by_type(resource_type)
return orders[0] if orders else None return orders[0] if orders else None
def get_orders_by_seller(self, seller_id: str) -> list[Order]: def get_orders_by_seller(self, seller_id: str) -> list[Order]:
"""Get all active orders from a specific seller.""" """Get all active orders from a specific seller."""
return [ return [
o for o in self.orders o for o in self.orders
if o.status == OrderStatus.ACTIVE and o.seller_id == seller_id if o.status == OrderStatus.ACTIVE and o.seller_id == seller_id
] ]
def cancel_seller_orders(self, seller_id: str) -> list[Order]: def cancel_seller_orders(self, seller_id: str) -> list[Order]:
"""Cancel all orders from a seller (e.g., when they die). Returns cancelled orders.""" """Cancel all orders from a seller (e.g., when they die). Returns cancelled orders."""
cancelled = [] cancelled = []
@ -223,7 +232,7 @@ class OrderBook:
order.status = OrderStatus.CANCELLED order.status = OrderStatus.CANCELLED
cancelled.append(order) cancelled.append(order)
return cancelled return cancelled
def execute_buy( def execute_buy(
self, self,
buyer_id: str, buyer_id: str,
@ -238,13 +247,13 @@ class OrderBook:
if o.id == order_id and o.status == OrderStatus.ACTIVE: if o.id == order_id and o.status == OrderStatus.ACTIVE:
order = o order = o
break break
if order is None: if order is None:
return TradeResult( return TradeResult(
success=False, success=False,
message="Order not found or no longer active", message="Order not found or no longer active",
) )
# Check quantity # Check quantity
actual_quantity = min(quantity, order.quantity) actual_quantity = min(quantity, order.quantity)
if actual_quantity <= 0: if actual_quantity <= 0:
@ -252,7 +261,7 @@ class OrderBook:
success=False, success=False,
message="Invalid quantity", message="Invalid quantity",
) )
# Check buyer has enough money # Check buyer has enough money
total_cost = actual_quantity * order.price_per_unit total_cost = actual_quantity * order.price_per_unit
if buyer_money < total_cost: if buyer_money < total_cost:
@ -265,12 +274,12 @@ class OrderBook:
message="Insufficient funds", message="Insufficient funds",
) )
total_cost = actual_quantity * order.price_per_unit total_cost = actual_quantity * order.price_per_unit
# Execute the trade # Execute the trade
order.quantity -= actual_quantity order.quantity -= actual_quantity
if order.quantity <= 0: if order.quantity <= 0:
order.status = OrderStatus.FILLED order.status = OrderStatus.FILLED
result = TradeResult( result = TradeResult(
success=True, success=True,
order_id=order.id, order_id=order.id,
@ -281,12 +290,12 @@ class OrderBook:
total_paid=total_cost, total_paid=total_cost,
message=f"Bought {actual_quantity} {order.resource_type.value} for {total_cost} coins", message=f"Bought {actual_quantity} {order.resource_type.value} for {total_cost} coins",
) )
# Record sale for price history (we need current_turn but don't have it here) # Record sale for price history (we need current_turn but don't have it here)
# The turn will be passed via the _record_sale call from engine # The turn will be passed via the _record_sale call from engine
self.trade_history.append(result) self.trade_history.append(result)
return result return result
def execute_multi_buy( def execute_multi_buy(
self, self,
buyer_id: str, buyer_id: str,
@ -296,30 +305,30 @@ class OrderBook:
"""Execute multiple buy orders in one action. Returns list of trade results.""" """Execute multiple buy orders in one action. Returns list of trade results."""
results = [] results = []
remaining_money = buyer_money remaining_money = buyer_money
for order_id, quantity in purchases: for order_id, quantity in purchases:
result = self.execute_buy(buyer_id, order_id, quantity, remaining_money) result = self.execute_buy(buyer_id, order_id, quantity, remaining_money)
results.append(result) results.append(result)
if result.success: if result.success:
remaining_money -= result.total_paid remaining_money -= result.total_paid
return results return results
def update_prices(self, current_turn: int) -> None: def update_prices(self, current_turn: int) -> None:
"""Update order prices and supply/demand scores.""" """Update order prices and supply/demand scores."""
# Update supply/demand scores # Update supply/demand scores
self._update_supply_demand_scores(current_turn) self._update_supply_demand_scores(current_turn)
# Apply automatic discounts to stale orders (keeping original behavior) # Apply automatic discounts to stale orders (keeping original behavior)
for order in self.orders: for order in self.orders:
if order.status != OrderStatus.ACTIVE: if order.status != OrderStatus.ACTIVE:
continue continue
turns_waiting = current_turn - order.created_turn turns_waiting = current_turn - order.created_turn
if turns_waiting > 0 and turns_waiting % self.TURNS_BEFORE_DISCOUNT == 0: if turns_waiting > 0 and turns_waiting % self.TURNS_BEFORE_DISCOUNT == 0:
order.turns_without_sale = turns_waiting order.turns_without_sale = turns_waiting
order.apply_discount(self.DISCOUNT_RATE) order.apply_discount(self.DISCOUNT_RATE)
def _update_supply_demand_scores(self, current_turn: int) -> None: def _update_supply_demand_scores(self, current_turn: int) -> None:
"""Calculate current supply and demand scores for each resource.""" """Calculate current supply and demand scores for each resource."""
for resource_type in ResourceType: for resource_type in ResourceType:
@ -327,7 +336,7 @@ class OrderBook:
if not history: if not history:
history = PriceHistory() history = PriceHistory()
self.price_history[resource_type] = history self.price_history[resource_type] = history
# Calculate supply score based on available quantity # Calculate supply score based on available quantity
total_supply = self.get_total_supply(resource_type) total_supply = self.get_total_supply(resource_type)
if total_supply <= self.LOW_SUPPLY_THRESHOLD: if total_supply <= self.LOW_SUPPLY_THRESHOLD:
@ -336,36 +345,36 @@ class OrderBook:
history.supply_score = min(1.0, 0.5 + (total_supply / self.HIGH_SUPPLY_THRESHOLD) * 0.5) history.supply_score = min(1.0, 0.5 + (total_supply / self.HIGH_SUPPLY_THRESHOLD) * 0.5)
else: else:
history.supply_score = 0.5 history.supply_score = 0.5
# Decay demand score over time # Decay demand score over time
history.demand_score *= self.DEMAND_DECAY history.demand_score *= self.DEMAND_DECAY
def get_total_supply(self, resource_type: ResourceType) -> int: def get_total_supply(self, resource_type: ResourceType) -> int:
"""Get total quantity available for a resource type.""" """Get total quantity available for a resource type."""
return sum(o.quantity for o in self.get_orders_by_type(resource_type)) return sum(o.quantity for o in self.get_orders_by_type(resource_type))
def get_supply_demand_ratio(self, resource_type: ResourceType) -> float: def get_supply_demand_ratio(self, resource_type: ResourceType) -> float:
"""Get supply/demand ratio. <1 means scarcity, >1 means surplus.""" """Get supply/demand ratio. <1 means scarcity, >1 means surplus."""
history = self.price_history.get(resource_type, PriceHistory()) history = self.price_history.get(resource_type, PriceHistory())
demand = max(0.1, history.demand_score) demand = max(0.1, history.demand_score)
supply = max(0.1, history.supply_score) supply = max(0.1, history.supply_score)
return supply / demand return supply / demand
def get_suggested_price(self, resource_type: ResourceType, base_price: int) -> int: def get_suggested_price(self, resource_type: ResourceType, base_price: int) -> int:
"""Suggest a price based on supply/demand conditions. """Suggest a price based on supply/demand conditions.
Returns an adjusted price that accounts for market conditions: Returns an adjusted price that accounts for market conditions:
- Scarcity (low supply, high demand) -> higher price - Scarcity (low supply, high demand) -> higher price
- Surplus (high supply, low demand) -> lower price - Surplus (high supply, low demand) -> lower price
""" """
ratio = self.get_supply_demand_ratio(resource_type) ratio = self.get_supply_demand_ratio(resource_type)
history = self.price_history.get(resource_type, PriceHistory()) history = self.price_history.get(resource_type, PriceHistory())
# Use average sale price as reference if available # Use average sale price as reference if available
reference_price = base_price reference_price = base_price
if history.avg_sale_price > 0: if history.avg_sale_price > 0:
reference_price = int((base_price + history.avg_sale_price) / 2) reference_price = int((base_price + history.avg_sale_price) / 2)
# Adjust based on supply/demand # Adjust based on supply/demand
if ratio < 0.7: # Scarcity - raise price if ratio < 0.7: # Scarcity - raise price
price_multiplier = 1.0 + (0.7 - ratio) * 0.5 # Up to 35% increase price_multiplier = 1.0 + (0.7 - ratio) * 0.5 # Up to 35% increase
@ -374,10 +383,10 @@ class OrderBook:
price_multiplier = max(0.5, price_multiplier) # Floor at 50% price_multiplier = max(0.5, price_multiplier) # Floor at 50%
else: else:
price_multiplier = 1.0 price_multiplier = 1.0
suggested = int(reference_price * price_multiplier) suggested = int(reference_price * price_multiplier)
return max(1, suggested) return max(_get_min_price(), suggested)
def adjust_order_price(self, order_id: str, seller_id: str, new_price: int, current_turn: int) -> bool: 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."""
for order in self.orders: for order in self.orders:
@ -385,37 +394,37 @@ class OrderBook:
if order.status == OrderStatus.ACTIVE: if order.status == OrderStatus.ACTIVE:
return order.adjust_price(new_price, current_turn) return order.adjust_price(new_price, current_turn)
return False return False
def _record_sale(self, resource_type: ResourceType, price: int, quantity: int, current_turn: int) -> None: def _record_sale(self, resource_type: ResourceType, price: int, quantity: int, current_turn: int) -> None:
"""Record a sale for price history tracking.""" """Record a sale for price history tracking."""
history = self.price_history.get(resource_type) history = self.price_history.get(resource_type)
if not history: if not history:
history = PriceHistory() history = PriceHistory()
self.price_history[resource_type] = history self.price_history[resource_type] = history
history.last_sale_price = price history.last_sale_price = price
history.last_sale_turn = current_turn history.last_sale_turn = current_turn
# Update average sale price # Update average sale price
old_total = history.avg_sale_price * history.total_sold old_total = history.avg_sale_price * history.total_sold
history.total_sold += quantity history.total_sold += quantity
history.avg_sale_price = (old_total + price * quantity) / history.total_sold history.avg_sale_price = (old_total + price * quantity) / history.total_sold
# Increase demand score when sales happen # Increase demand score when sales happen
history.demand_score = min(1.0, history.demand_score + 0.1 * quantity) history.demand_score = min(1.0, history.demand_score + 0.1 * quantity)
def cleanup_old_orders(self, max_age: int = 50) -> list[Order]: def cleanup_old_orders(self, max_age: int = 50) -> list[Order]:
"""Remove very old orders. Returns removed orders.""" """Remove very old orders. Returns removed orders."""
# For now, we don't auto-expire orders, but this could be enabled # For now, we don't auto-expire orders, but this could be enabled
return [] return []
def get_market_prices(self) -> dict[str, dict]: def get_market_prices(self) -> dict[str, dict]:
"""Get current market price summary for each resource.""" """Get current market price summary for each resource."""
prices = {} prices = {}
for resource_type in ResourceType: for resource_type in ResourceType:
orders = self.get_orders_by_type(resource_type) orders = self.get_orders_by_type(resource_type)
history = self.price_history.get(resource_type, PriceHistory()) history = self.price_history.get(resource_type, PriceHistory())
if orders: if orders:
prices[resource_type.value] = { prices[resource_type.value] = {
"lowest_price": orders[0].price_per_unit, "lowest_price": orders[0].price_per_unit,
@ -437,7 +446,7 @@ class OrderBook:
"demand_score": round(history.demand_score, 2), "demand_score": round(history.demand_score, 2),
} }
return prices return prices
def get_market_signal(self, resource_type: ResourceType) -> str: def get_market_signal(self, resource_type: ResourceType) -> str:
"""Get a simple market signal for a resource: 'sell', 'hold', or 'buy'.""" """Get a simple market signal for a resource: 'sell', 'hold', or 'buy'."""
ratio = self.get_supply_demand_ratio(resource_type) ratio = self.get_supply_demand_ratio(resource_type)
@ -446,7 +455,7 @@ class OrderBook:
elif ratio > 1.3: elif ratio > 1.3:
return "buy" # Good time to buy - surplus return "buy" # Good time to buy - surplus
return "hold" return "hold"
def get_state_snapshot(self) -> dict: def get_state_snapshot(self) -> dict:
"""Get market state for API.""" """Get market state for API."""
return { return {

View File

@ -1,4 +1,10 @@
{ {
"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,
@ -19,27 +25,27 @@
"meat_decay": 10, "meat_decay": 10,
"berries_decay": 6, "berries_decay": 6,
"clothes_decay": 20, "clothes_decay": 20,
"meat_hunger": 35, "meat_hunger": 45,
"meat_energy": 12, "meat_energy": 15,
"berries_hunger": 10, "berries_hunger": 8,
"berries_thirst": 4, "berries_thirst": 2,
"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": -7, "hunt_energy": -5,
"gather_energy": -3, "gather_energy": -4,
"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.70, "hunt_success": 0.85,
"chop_wood_success": 0.90, "chop_wood_success": 0.9,
"hunt_meat_min": 2, "hunt_meat_min": 2,
"hunt_meat_max": 5, "hunt_meat_max": 4,
"hunt_hide_min": 0, "hunt_hide_min": 0,
"hunt_hide_max": 2, "hunt_hide_max": 2,
"gather_min": 2, "gather_min": 2,
@ -54,7 +60,7 @@
"day_steps": 10, "day_steps": 10,
"night_steps": 1, "night_steps": 1,
"inventory_slots": 12, "inventory_slots": 12,
"starting_money": 80 "starting_money": 8000
}, },
"market": { "market": {
"turns_before_discount": 15, "turns_before_discount": 15,
@ -62,10 +68,11 @@
"base_price_multiplier": 1.3 "base_price_multiplier": 1.3
}, },
"economy": { "economy": {
"energy_to_money_ratio": 1.5, "energy_to_money_ratio": 150,
"min_price": 100,
"wealth_desire": 0.35, "wealth_desire": 0.35,
"buy_efficiency_threshold": 0.75, "buy_efficiency_threshold": 0.75,
"min_wealth_target": 50, "min_wealth_target": 5000,
"max_price_markup": 2.5, "max_price_markup": 2.5,
"min_price_discount": 0.4 "min_price_discount": 0.4
}, },

View File

@ -0,0 +1,79 @@
{
"ai": {
"use_goap": true,
"goap_max_iterations": 50,
"goap_max_plan_depth": 3,
"reactive_fallback": true
},
"agent_stats": {
"max_energy": 50,
"max_hunger": 100,
"max_thirst": 100,
"max_heat": 100,
"start_energy": 50,
"start_hunger": 70,
"start_thirst": 75,
"start_heat": 100,
"energy_decay": 1,
"hunger_decay": 2,
"thirst_decay": 3,
"heat_decay": 3,
"critical_threshold": 0.25,
"low_energy_threshold": 12
},
"resources": {
"meat_decay": 10,
"berries_decay": 6,
"clothes_decay": 20,
"meat_hunger": 45,
"meat_energy": 15,
"berries_hunger": 8,
"berries_thirst": 2,
"water_thirst": 50,
"fire_heat": 20
},
"actions": {
"sleep_energy": 55,
"rest_energy": 12,
"hunt_energy": -5,
"gather_energy": -4,
"chop_wood_energy": -6,
"get_water_energy": -2,
"weave_energy": -6,
"build_fire_energy": -4,
"trade_energy": -1,
"hunt_success": 0.85,
"chop_wood_success": 0.9,
"hunt_meat_min": 2,
"hunt_meat_max": 4,
"hunt_hide_min": 0,
"hunt_hide_max": 2,
"gather_min": 2,
"gather_max": 4,
"chop_wood_min": 1,
"chop_wood_max": 3
},
"world": {
"width": 25,
"height": 25,
"initial_agents": 25,
"day_steps": 10,
"night_steps": 1,
"inventory_slots": 12,
"starting_money": 80
},
"market": {
"turns_before_discount": 15,
"discount_rate": 0.12,
"base_price_multiplier": 1.3
},
"economy": {
"energy_to_money_ratio": 1.5,
"wealth_desire": 0.35,
"buy_efficiency_threshold": 0.75,
"min_wealth_target": 50,
"max_price_markup": 2.5,
"min_price_discount": 0.4
},
"auto_step_interval": 0.15
}

View File

@ -28,7 +28,7 @@ class ChartColors:
GRID = '#2f3545' GRID = '#2f3545'
TEXT = '#e0e0e8' TEXT = '#e0e0e8'
TEXT_DIM = '#7a7e8c' TEXT_DIM = '#7a7e8c'
# Neon accents for data series # Neon accents for data series
CYAN = '#00d4ff' CYAN = '#00d4ff'
MAGENTA = '#ff0099' MAGENTA = '#ff0099'
@ -38,7 +38,7 @@ class ChartColors:
YELLOW = '#ffcc00' YELLOW = '#ffcc00'
TEAL = '#00ffa3' TEAL = '#00ffa3'
PINK = '#ff1493' PINK = '#ff1493'
# Series colors for different resources/categories # Series colors for different resources/categories
SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK] SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK]
@ -60,26 +60,26 @@ class UIColors:
class HistoryData: class HistoryData:
"""Stores historical simulation data for charting.""" """Stores historical simulation data for charting."""
max_history: int = 200 max_history: int = 200
# Time series data # Time series data
turns: deque = field(default_factory=lambda: deque(maxlen=200)) turns: deque = field(default_factory=lambda: deque(maxlen=200))
population: deque = field(default_factory=lambda: deque(maxlen=200)) population: deque = field(default_factory=lambda: deque(maxlen=200))
deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=200)) deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=200))
# Money/Wealth data # Money/Wealth data
total_money: deque = field(default_factory=lambda: deque(maxlen=200)) total_money: deque = field(default_factory=lambda: deque(maxlen=200))
avg_wealth: deque = field(default_factory=lambda: deque(maxlen=200)) avg_wealth: deque = field(default_factory=lambda: deque(maxlen=200))
gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=200)) gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=200))
# Price history per resource # Price history per resource
prices: dict = field(default_factory=dict) # resource -> deque of prices prices: dict = field(default_factory=dict) # resource -> deque of prices
# Trade statistics # Trade statistics
trade_volume: deque = field(default_factory=lambda: deque(maxlen=200)) trade_volume: deque = field(default_factory=lambda: deque(maxlen=200))
# Profession counts over time # Profession counts over time
professions: dict = field(default_factory=dict) # profession -> deque of counts professions: dict = field(default_factory=dict) # profession -> deque of counts
def clear(self) -> None: def clear(self) -> None:
"""Clear all history data.""" """Clear all history data."""
self.turns.clear() self.turns.clear()
@ -91,51 +91,51 @@ class HistoryData:
self.prices.clear() self.prices.clear()
self.trade_volume.clear() self.trade_volume.clear()
self.professions.clear() self.professions.clear()
def update(self, state: "SimulationState") -> None: def update(self, state: "SimulationState") -> None:
"""Update history with new state data.""" """Update history with new state data."""
turn = state.turn turn = state.turn
# Avoid duplicate entries for the same turn # Avoid duplicate entries for the same turn
if self.turns and self.turns[-1] == turn: if self.turns and self.turns[-1] == turn:
return return
self.turns.append(turn) self.turns.append(turn)
# Population # Population
living = len([a for a in state.agents if a.get("is_alive", False)]) living = len([a for a in state.agents if a.get("is_alive", False)])
self.population.append(living) self.population.append(living)
self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0)) self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0))
# Wealth data # Wealth data
stats = state.statistics stats = state.statistics
self.total_money.append(stats.get("total_money_in_circulation", 0)) self.total_money.append(stats.get("total_money_in_circulation", 0))
self.avg_wealth.append(stats.get("avg_money", 0)) self.avg_wealth.append(stats.get("avg_money", 0))
self.gini_coefficient.append(stats.get("gini_coefficient", 0)) self.gini_coefficient.append(stats.get("gini_coefficient", 0))
# Price history from market # Price history from market
for resource, data in state.market_prices.items(): for resource, data in state.market_prices.items():
if resource not in self.prices: if resource not in self.prices:
self.prices[resource] = deque(maxlen=self.max_history) self.prices[resource] = deque(maxlen=self.max_history)
# Track lowest price (current market rate) # Track lowest price (current market rate)
lowest = data.get("lowest_price") lowest = data.get("lowest_price")
avg = data.get("avg_sale_price") avg = data.get("avg_sale_price")
# Use lowest price if available, else avg sale price # Use lowest price if available, else avg sale price
price = lowest if lowest is not None else avg price = lowest if lowest is not None else avg
self.prices[resource].append(price) self.prices[resource].append(price)
# Trade volume (from recent trades in market orders) # Trade volume (from recent trades in market orders)
trades = len(state.market_orders) # Active orders as proxy trades = len(state.market_orders) # Active orders as proxy
self.trade_volume.append(trades) self.trade_volume.append(trades)
# Profession distribution # Profession distribution
professions = stats.get("professions", {}) professions = stats.get("professions", {})
for prof, count in professions.items(): for prof, count in professions.items():
if prof not in self.professions: if prof not in self.professions:
self.professions[prof] = deque(maxlen=self.max_history) self.professions[prof] = deque(maxlen=self.max_history)
self.professions[prof].append(count) self.professions[prof].append(count)
# Pad missing professions with 0 # Pad missing professions with 0
for prof in self.professions: for prof in self.professions:
if prof not in professions: if prof not in professions:
@ -144,12 +144,12 @@ class HistoryData:
class ChartRenderer: class ChartRenderer:
"""Renders matplotlib charts to pygame surfaces.""" """Renders matplotlib charts to pygame surfaces."""
def __init__(self, width: int, height: int): def __init__(self, width: int, height: int):
self.width = width self.width = width
self.height = height self.height = height
self.dpi = 100 self.dpi = 100
# Configure matplotlib style # Configure matplotlib style
plt.style.use('dark_background') plt.style.use('dark_background')
plt.rcParams.update({ plt.rcParams.update({
@ -168,27 +168,27 @@ class ChartRenderer:
'axes.titlesize': 11, 'axes.titlesize': 11,
'axes.titleweight': 'bold', 'axes.titleweight': 'bold',
}) })
def _fig_to_surface(self, fig: Figure) -> pygame.Surface: def _fig_to_surface(self, fig: Figure) -> pygame.Surface:
"""Convert a matplotlib figure to a pygame surface.""" """Convert a matplotlib figure to a pygame surface."""
buf = io.BytesIO() buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=self.dpi, fig.savefig(buf, format='png', dpi=self.dpi,
facecolor=ChartColors.BG, edgecolor='none', facecolor=ChartColors.BG, edgecolor='none',
bbox_inches='tight', pad_inches=0.1) bbox_inches='tight', pad_inches=0.1)
buf.seek(0) buf.seek(0)
surface = pygame.image.load(buf, 'png') surface = pygame.image.load(buf, 'png')
buf.close() buf.close()
plt.close(fig) plt.close(fig)
return surface return surface
def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface: def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render price history chart for all resources.""" """Render price history chart for all resources."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
turns = list(history.turns) if history.turns else [0] turns = list(history.turns) if history.turns else [0]
has_data = False has_data = False
for i, (resource, prices) in enumerate(history.prices.items()): for i, (resource, prices) in enumerate(history.prices.items()):
if prices and any(p is not None for p in prices): if prices and any(p is not None for p in prices):
@ -197,46 +197,46 @@ class ChartRenderer:
valid_prices = [p if p is not None else 0 for p in prices] valid_prices = [p if p is not None else 0 for p in prices]
# Align with turns # Align with turns
min_len = min(len(turns), len(valid_prices)) min_len = min(len(turns), len(valid_prices))
ax.plot(list(turns)[-min_len:], valid_prices[-min_len:], ax.plot(list(turns)[-min_len:], valid_prices[-min_len:],
color=color, linewidth=1.5, label=resource.capitalize(), alpha=0.9) color=color, linewidth=1.5, label=resource.capitalize(), alpha=0.9)
has_data = True has_data = True
ax.set_title('Market Prices', color=ChartColors.CYAN) ax.set_title('Market Prices', color=ChartColors.CYAN)
ax.set_xlabel('Turn') ax.set_xlabel('Turn')
ax.set_ylabel('Price (coins)') ax.set_ylabel('Price (coins)')
ax.grid(True, alpha=0.2) ax.grid(True, alpha=0.2)
if has_data: if has_data:
ax.legend(loc='upper left', fontsize=8, framealpha=0.8) ax.legend(loc='upper left', fontsize=8, framealpha=0.8)
ax.set_ylim(bottom=0) ax.set_ylim(bottom=0)
ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface: def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render population over time chart.""" """Render population over time chart."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
turns = list(history.turns) if history.turns else [0] turns = list(history.turns) if history.turns else [0]
population = list(history.population) if history.population else [0] population = list(history.population) if history.population else [0]
deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0] deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0]
min_len = min(len(turns), len(population)) min_len = min(len(turns), len(population))
# Population line # Population line
ax.fill_between(turns[-min_len:], population[-min_len:], ax.fill_between(turns[-min_len:], population[-min_len:],
alpha=0.3, color=ChartColors.CYAN) alpha=0.3, color=ChartColors.CYAN)
ax.plot(turns[-min_len:], population[-min_len:], ax.plot(turns[-min_len:], population[-min_len:],
color=ChartColors.CYAN, linewidth=2, label='Living') color=ChartColors.CYAN, linewidth=2, label='Living')
# Deaths line # Deaths line
if deaths: if deaths:
ax.plot(turns[-min_len:], deaths[-min_len:], ax.plot(turns[-min_len:], deaths[-min_len:],
color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--', color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--',
label='Total Deaths', alpha=0.8) label='Total Deaths', alpha=0.8)
ax.set_title('Population Over Time', color=ChartColors.LIME) ax.set_title('Population Over Time', color=ChartColors.LIME)
ax.set_xlabel('Turn') ax.set_xlabel('Turn')
ax.set_ylabel('Count') ax.set_ylabel('Count')
@ -244,28 +244,28 @@ class ChartRenderer:
ax.legend(loc='upper right', fontsize=8) ax.legend(loc='upper right', fontsize=8)
ax.set_ylim(bottom=0) ax.set_ylim(bottom=0)
ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface:
"""Render current wealth distribution as a bar chart.""" """Render current wealth distribution as a bar chart."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Get agent wealth data # Get agent wealth data
agents = [a for a in state.agents if a.get("is_alive", False)] agents = [a for a in state.agents if a.get("is_alive", False)]
if not agents: if not agents:
ax.text(0.5, 0.5, 'No living agents', ha='center', va='center', ax.text(0.5, 0.5, 'No living agents', ha='center', va='center',
color=ChartColors.TEXT_DIM, fontsize=12) color=ChartColors.TEXT_DIM, fontsize=12)
ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
# Sort by wealth # Sort by wealth
agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True) agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True)
names = [a.get("name", "?")[:8] for a in agents_sorted] names = [a.get("name", "?")[:8] for a in agents_sorted]
wealth = [a.get("money", 0) for a in agents_sorted] wealth = [a.get("money", 0) for a in agents_sorted]
# Create gradient colors based on wealth ranking # Create gradient colors based on wealth ranking
colors = [] colors = []
for i in range(len(agents_sorted)): for i in range(len(agents_sorted)):
@ -275,86 +275,86 @@ class ChartRenderer:
g = int(212 - ratio * 212) g = int(212 - ratio * 212)
b = int(255 - ratio * 102) b = int(255 - ratio * 102)
colors.append(f'#{r:02x}{g:02x}{b:02x}') colors.append(f'#{r:02x}{g:02x}{b:02x}')
bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85) bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85)
ax.set_yticks(range(len(agents_sorted))) ax.set_yticks(range(len(agents_sorted)))
ax.set_yticklabels(names, fontsize=7) ax.set_yticklabels(names, fontsize=7)
ax.invert_yaxis() # Rich at top ax.invert_yaxis() # Rich at top
# Add value labels # Add value labels
for bar, val in zip(bars, wealth): for bar, val in zip(bars, wealth):
ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2, ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2,
f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM) f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM)
ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
ax.set_xlabel('Coins') ax.set_xlabel('Coins')
ax.grid(True, alpha=0.2, axis='x') ax.grid(True, alpha=0.2, axis='x')
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface: def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render wealth metrics over time (total money, avg, gini).""" """Render wealth metrics over time (total money, avg, gini)."""
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(width/self.dpi, height/self.dpi), fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(width/self.dpi, height/self.dpi),
dpi=self.dpi, height_ratios=[2, 1]) dpi=self.dpi, height_ratios=[2, 1])
turns = list(history.turns) if history.turns else [0] turns = list(history.turns) if history.turns else [0]
total = list(history.total_money) if history.total_money else [0] total = list(history.total_money) if history.total_money else [0]
avg = list(history.avg_wealth) if history.avg_wealth else [0] avg = list(history.avg_wealth) if history.avg_wealth else [0]
gini = list(history.gini_coefficient) if history.gini_coefficient else [0] gini = list(history.gini_coefficient) if history.gini_coefficient else [0]
min_len = min(len(turns), len(total), len(avg)) min_len = min(len(turns), len(total), len(avg))
# Total and average wealth # Total and average wealth
ax1.plot(turns[-min_len:], total[-min_len:], ax1.plot(turns[-min_len:], total[-min_len:],
color=ChartColors.CYAN, linewidth=2, label='Total Money') color=ChartColors.CYAN, linewidth=2, label='Total Money')
ax1.fill_between(turns[-min_len:], total[-min_len:], ax1.fill_between(turns[-min_len:], total[-min_len:],
alpha=0.2, color=ChartColors.CYAN) alpha=0.2, color=ChartColors.CYAN)
ax1_twin = ax1.twinx() ax1_twin = ax1.twinx()
ax1_twin.plot(turns[-min_len:], avg[-min_len:], ax1_twin.plot(turns[-min_len:], avg[-min_len:],
color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg Wealth') color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg Wealth')
ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME) ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME)
ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME) ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME)
ax1.set_title('Money in Circulation', color=ChartColors.YELLOW) ax1.set_title('Money in Circulation', color=ChartColors.YELLOW)
ax1.set_ylabel('Total Money', color=ChartColors.CYAN) ax1.set_ylabel('Total Money', color=ChartColors.CYAN)
ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN) ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN)
ax1.grid(True, alpha=0.2) ax1.grid(True, alpha=0.2)
ax1.set_ylim(bottom=0) ax1.set_ylim(bottom=0)
# Gini coefficient (inequality) # Gini coefficient (inequality)
min_len_gini = min(len(turns), len(gini)) min_len_gini = min(len(turns), len(gini))
ax2.fill_between(turns[-min_len_gini:], gini[-min_len_gini:], ax2.fill_between(turns[-min_len_gini:], gini[-min_len_gini:],
alpha=0.4, color=ChartColors.MAGENTA) alpha=0.4, color=ChartColors.MAGENTA)
ax2.plot(turns[-min_len_gini:], gini[-min_len_gini:], ax2.plot(turns[-min_len_gini:], gini[-min_len_gini:],
color=ChartColors.MAGENTA, linewidth=1.5) color=ChartColors.MAGENTA, linewidth=1.5)
ax2.set_xlabel('Turn') ax2.set_xlabel('Turn')
ax2.set_ylabel('Gini') ax2.set_ylabel('Gini')
ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9) ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9)
ax2.set_ylim(0, 1) ax2.set_ylim(0, 1)
ax2.grid(True, alpha=0.2) ax2.grid(True, alpha=0.2)
# Add reference lines for gini # Add reference lines for gini
ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5, linewidth=1) ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5, linewidth=1)
ax2.text(turns[-1] if turns else 0, 0.42, 'Moderate', fontsize=7, ax2.text(turns[-1] if turns else 0, 0.42, 'Moderate', fontsize=7,
color=ChartColors.YELLOW, alpha=0.7) color=ChartColors.YELLOW, alpha=0.7)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_professions(self, state: "SimulationState", history: HistoryData, def render_professions(self, state: "SimulationState", history: HistoryData,
width: int, height: int) -> pygame.Surface: width: int, height: int) -> pygame.Surface:
"""Render profession distribution as pie chart and area chart.""" """Render profession distribution as pie chart and area chart."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Current profession pie chart # Current profession pie chart
professions = state.statistics.get("professions", {}) professions = state.statistics.get("professions", {})
if professions: if professions:
labels = list(professions.keys()) labels = list(professions.keys())
sizes = list(professions.values()) sizes = list(professions.values())
colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))] colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))]
wedges, texts, autotexts = ax1.pie( wedges, texts, autotexts = ax1.pie(
sizes, labels=labels, colors=colors, autopct='%1.0f%%', sizes, labels=labels, colors=colors, autopct='%1.0f%%',
startangle=90, pctdistance=0.75, startangle=90, pctdistance=0.75,
@ -367,7 +367,7 @@ class ChartRenderer:
else: else:
ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM) ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM)
ax1.set_title('Current Distribution', color=ChartColors.PURPLE) ax1.set_title('Current Distribution', color=ChartColors.PURPLE)
# Profession history as stacked area # Profession history as stacked area
turns = list(history.turns) if history.turns else [0] turns = list(history.turns) if history.turns else [0]
if history.professions and turns: if history.professions and turns:
@ -379,37 +379,37 @@ class ChartRenderer:
while len(prof_data) < len(turns): while len(prof_data) < len(turns):
prof_data.insert(0, 0) prof_data.insert(0, 0)
data.append(prof_data[-len(turns):]) data.append(prof_data[-len(turns):])
colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(profs_list))] colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(profs_list))]
ax2.stackplot(turns, *data, labels=profs_list, colors=colors, alpha=0.8) ax2.stackplot(turns, *data, labels=profs_list, colors=colors, alpha=0.8)
ax2.legend(loc='upper left', fontsize=7, framealpha=0.8) ax2.legend(loc='upper left', fontsize=7, framealpha=0.8)
ax2.set_xlabel('Turn') ax2.set_xlabel('Turn')
ax2.set_ylabel('Count') ax2.set_ylabel('Count')
ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10) ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10)
ax2.grid(True, alpha=0.2) ax2.grid(True, alpha=0.2)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_market_activity(self, state: "SimulationState", history: HistoryData, def render_market_activity(self, state: "SimulationState", history: HistoryData,
width: int, height: int) -> pygame.Surface: width: int, height: int) -> pygame.Surface:
"""Render market activity - orders by resource, supply/demand.""" """Render market activity - orders by resource, supply/demand."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Current market orders by resource type # Current market orders by resource type
prices = state.market_prices prices = state.market_prices
resources = [] resources = []
quantities = [] quantities = []
colors = [] colors = []
for i, (resource, data) in enumerate(prices.items()): for i, (resource, data) in enumerate(prices.items()):
qty = data.get("total_available", 0) qty = data.get("total_available", 0)
if qty > 0: if qty > 0:
resources.append(resource.capitalize()) resources.append(resource.capitalize())
quantities.append(qty) quantities.append(qty)
colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)]) colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)])
if resources: if resources:
bars = ax1.bar(resources, quantities, color=colors, alpha=0.85) bars = ax1.bar(resources, quantities, color=colors, alpha=0.85)
ax1.set_ylabel('Available') ax1.set_ylabel('Available')
@ -418,93 +418,93 @@ class ChartRenderer:
str(val), ha='center', fontsize=8, color=ChartColors.TEXT) str(val), ha='center', fontsize=8, color=ChartColors.TEXT)
else: else:
ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM) ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM)
ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10) ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10)
ax1.tick_params(axis='x', rotation=45, labelsize=7) ax1.tick_params(axis='x', rotation=45, labelsize=7)
ax1.grid(True, alpha=0.2, axis='y') ax1.grid(True, alpha=0.2, axis='y')
# Supply/Demand scores # Supply/Demand scores
resources_sd = [] resources_sd = []
supply_scores = [] supply_scores = []
demand_scores = [] demand_scores = []
for resource, data in prices.items(): for resource, data in prices.items():
resources_sd.append(resource[:6]) resources_sd.append(resource[:6])
supply_scores.append(data.get("supply_score", 0.5)) supply_scores.append(data.get("supply_score", 0.5))
demand_scores.append(data.get("demand_score", 0.5)) demand_scores.append(data.get("demand_score", 0.5))
if resources_sd: if resources_sd:
x = np.arange(len(resources_sd)) x = np.arange(len(resources_sd))
width_bar = 0.35 width_bar = 0.35
ax2.bar(x - width_bar/2, supply_scores, width_bar, label='Supply', ax2.bar(x - width_bar/2, supply_scores, width_bar, label='Supply',
color=ChartColors.CYAN, alpha=0.8) color=ChartColors.CYAN, alpha=0.8)
ax2.bar(x + width_bar/2, demand_scores, width_bar, label='Demand', ax2.bar(x + width_bar/2, demand_scores, width_bar, label='Demand',
color=ChartColors.MAGENTA, alpha=0.8) color=ChartColors.MAGENTA, alpha=0.8)
ax2.set_xticks(x) ax2.set_xticks(x)
ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45) ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45)
ax2.set_ylabel('Score') ax2.set_ylabel('Score')
ax2.legend(fontsize=7) ax2.legend(fontsize=7)
ax2.set_ylim(0, 1.2) ax2.set_ylim(0, 1.2)
ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10) ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10)
ax2.grid(True, alpha=0.2, axis='y') ax2.grid(True, alpha=0.2, axis='y')
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface:
"""Render aggregate agent statistics - energy, hunger, thirst distributions.""" """Render aggregate agent statistics - energy, hunger, thirst distributions."""
fig, axes = plt.subplots(2, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, axes = plt.subplots(2, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
agents = [a for a in state.agents if a.get("is_alive", False)] agents = [a for a in state.agents if a.get("is_alive", False)]
if not agents: if not agents:
for ax in axes.flat: for ax in axes.flat:
ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM) ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM)
fig.suptitle('Agent Statistics', color=ChartColors.CYAN) fig.suptitle('Agent Statistics', color=ChartColors.CYAN)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
# Extract stats # Extract stats
energies = [a.get("stats", {}).get("energy", 0) for a in agents] energies = [a.get("stats", {}).get("energy", 0) for a in agents]
hungers = [a.get("stats", {}).get("hunger", 0) for a in agents] hungers = [a.get("stats", {}).get("hunger", 0) for a in agents]
thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents] thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents]
heats = [a.get("stats", {}).get("heat", 0) for a in agents] heats = [a.get("stats", {}).get("heat", 0) for a in agents]
max_energy = agents[0].get("stats", {}).get("max_energy", 100) max_energy = agents[0].get("stats", {}).get("max_energy", 100)
max_hunger = agents[0].get("stats", {}).get("max_hunger", 100) max_hunger = agents[0].get("stats", {}).get("max_hunger", 100)
max_thirst = agents[0].get("stats", {}).get("max_thirst", 100) max_thirst = agents[0].get("stats", {}).get("max_thirst", 100)
max_heat = agents[0].get("stats", {}).get("max_heat", 100) max_heat = agents[0].get("stats", {}).get("max_heat", 100)
stats_data = [ stats_data = [
(energies, max_energy, 'Energy', ChartColors.LIME), (energies, max_energy, 'Energy', ChartColors.LIME),
(hungers, max_hunger, 'Hunger', ChartColors.ORANGE), (hungers, max_hunger, 'Hunger', ChartColors.ORANGE),
(thirsts, max_thirst, 'Thirst', ChartColors.CYAN), (thirsts, max_thirst, 'Thirst', ChartColors.CYAN),
(heats, max_heat, 'Heat', ChartColors.MAGENTA), (heats, max_heat, 'Heat', ChartColors.MAGENTA),
] ]
for ax, (values, max_val, name, color) in zip(axes.flat, stats_data): for ax, (values, max_val, name, color) in zip(axes.flat, stats_data):
# Histogram # Histogram
bins = np.linspace(0, max_val, 11) bins = np.linspace(0, max_val, 11)
ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL) ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL)
# Mean line # Mean line
mean_val = np.mean(values) mean_val = np.mean(values)
ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--', ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--',
linewidth=1.5, label=f'Avg: {mean_val:.0f}') linewidth=1.5, label=f'Avg: {mean_val:.0f}')
# Critical threshold # Critical threshold
critical = max_val * 0.25 critical = max_val * 0.25
ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':', ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':',
linewidth=1, alpha=0.7) linewidth=1, alpha=0.7)
ax.set_title(name, color=color, fontsize=9) ax.set_title(name, color=color, fontsize=9)
ax.set_xlim(0, max_val) ax.set_xlim(0, max_val)
ax.legend(fontsize=7, loc='upper right') ax.legend(fontsize=7, loc='upper right')
ax.grid(True, alpha=0.2) ax.grid(True, alpha=0.2)
fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11) fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
@ -512,7 +512,7 @@ class ChartRenderer:
class StatsRenderer: class StatsRenderer:
"""Main statistics panel with tabs and charts.""" """Main statistics panel with tabs and charts."""
TABS = [ TABS = [
("Prices", "price_history"), ("Prices", "price_history"),
("Wealth", "wealth"), ("Wealth", "wealth"),
@ -521,35 +521,35 @@ class StatsRenderer:
("Market", "market"), ("Market", "market"),
("Agent Stats", "agent_stats"), ("Agent Stats", "agent_stats"),
] ]
def __init__(self, screen: pygame.Surface): def __init__(self, screen: pygame.Surface):
self.screen = screen self.screen = screen
self.visible = False self.visible = False
self.font = pygame.font.Font(None, 24) self.font = pygame.font.Font(None, 24)
self.small_font = pygame.font.Font(None, 18) self.small_font = pygame.font.Font(None, 18)
self.title_font = pygame.font.Font(None, 32) self.title_font = pygame.font.Font(None, 32)
self.current_tab = 0 self.current_tab = 0
self.tab_hovered = -1 self.tab_hovered = -1
# History data # History data
self.history = HistoryData() self.history = HistoryData()
# Chart renderer # Chart renderer
self.chart_renderer: Optional[ChartRenderer] = None self.chart_renderer: Optional[ChartRenderer] = None
# Cached chart surfaces # Cached chart surfaces
self._chart_cache: dict[str, pygame.Surface] = {} self._chart_cache: dict[str, pygame.Surface] = {}
self._cache_turn: int = -1 self._cache_turn: int = -1
# Layout # Layout
self._calculate_layout() self._calculate_layout()
def _calculate_layout(self) -> None: def _calculate_layout(self) -> None:
"""Calculate panel layout based on screen size.""" """Calculate panel layout based on screen size."""
screen_w, screen_h = self.screen.get_size() screen_w, screen_h = self.screen.get_size()
# Panel takes most of the screen with some margin # Panel takes most of the screen with some margin
margin = 30 margin = 30
self.panel_rect = pygame.Rect( self.panel_rect = pygame.Rect(
@ -557,7 +557,7 @@ class StatsRenderer:
screen_w - margin * 2, screen_w - margin * 2,
screen_h - margin * 2 screen_h - margin * 2
) )
# Tab bar # Tab bar
self.tab_height = 40 self.tab_height = 40
self.tab_rect = pygame.Rect( self.tab_rect = pygame.Rect(
@ -566,7 +566,7 @@ class StatsRenderer:
self.panel_rect.width, self.panel_rect.width,
self.tab_height self.tab_height
) )
# Chart area # Chart area
self.chart_rect = pygame.Rect( self.chart_rect = pygame.Rect(
self.panel_rect.x + 10, self.panel_rect.x + 10,
@ -574,52 +574,52 @@ class StatsRenderer:
self.panel_rect.width - 20, self.panel_rect.width - 20,
self.panel_rect.height - self.tab_height - 20 self.panel_rect.height - self.tab_height - 20
) )
# Initialize chart renderer with chart area size # Initialize chart renderer with chart area size
self.chart_renderer = ChartRenderer( self.chart_renderer = ChartRenderer(
self.chart_rect.width, self.chart_rect.width,
self.chart_rect.height self.chart_rect.height
) )
# Calculate tab widths # Calculate tab widths
self.tab_width = self.panel_rect.width // len(self.TABS) self.tab_width = self.panel_rect.width // len(self.TABS)
def toggle(self) -> None: def toggle(self) -> None:
"""Toggle visibility of the stats panel.""" """Toggle visibility of the stats panel."""
self.visible = not self.visible self.visible = not self.visible
if self.visible: if self.visible:
self._invalidate_cache() self._invalidate_cache()
def update_history(self, state: "SimulationState") -> None: def update_history(self, state: "SimulationState") -> None:
"""Update history data with new state.""" """Update history data with new state."""
if state: if state:
self.history.update(state) self.history.update(state)
def clear_history(self) -> None: def clear_history(self) -> None:
"""Clear all history data (e.g., on simulation reset).""" """Clear all history data (e.g., on simulation reset)."""
self.history.clear() self.history.clear()
self._invalidate_cache() self._invalidate_cache()
def _invalidate_cache(self) -> None: def _invalidate_cache(self) -> None:
"""Invalidate chart cache to force re-render.""" """Invalidate chart cache to force re-render."""
self._chart_cache.clear() self._chart_cache.clear()
self._cache_turn = -1 self._cache_turn = -1
def handle_event(self, event: pygame.event.Event) -> bool: def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle input events. Returns True if event was consumed.""" """Handle input events. Returns True if event was consumed."""
if not self.visible: if not self.visible:
return False return False
if event.type == pygame.MOUSEMOTION: if event.type == pygame.MOUSEMOTION:
self._handle_mouse_motion(event.pos) self._handle_mouse_motion(event.pos)
return True return True
elif event.type == pygame.MOUSEBUTTONDOWN: elif event.type == pygame.MOUSEBUTTONDOWN:
if self._handle_click(event.pos): if self._handle_click(event.pos):
return True return True
# Consume clicks when visible # Consume clicks when visible
return True return True
elif event.type == pygame.KEYDOWN: elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: if event.key == pygame.K_ESCAPE:
self.toggle() self.toggle()
@ -632,19 +632,19 @@ class StatsRenderer:
self.current_tab = (self.current_tab + 1) % len(self.TABS) self.current_tab = (self.current_tab + 1) % len(self.TABS)
self._invalidate_cache() self._invalidate_cache()
return True return True
return False return False
def _handle_mouse_motion(self, pos: tuple[int, int]) -> None: def _handle_mouse_motion(self, pos: tuple[int, int]) -> None:
"""Handle mouse motion for tab hover effects.""" """Handle mouse motion for tab hover effects."""
self.tab_hovered = -1 self.tab_hovered = -1
if self.tab_rect.collidepoint(pos): if self.tab_rect.collidepoint(pos):
rel_x = pos[0] - self.tab_rect.x rel_x = pos[0] - self.tab_rect.x
tab_idx = rel_x // self.tab_width tab_idx = rel_x // self.tab_width
if 0 <= tab_idx < len(self.TABS): if 0 <= tab_idx < len(self.TABS):
self.tab_hovered = tab_idx self.tab_hovered = tab_idx
def _handle_click(self, pos: tuple[int, int]) -> bool: def _handle_click(self, pos: tuple[int, int]) -> bool:
"""Handle mouse click. Returns True if click was on a tab.""" """Handle mouse click. Returns True if click was on a tab."""
if self.tab_rect.collidepoint(pos): if self.tab_rect.collidepoint(pos):
@ -655,20 +655,20 @@ class StatsRenderer:
self._invalidate_cache() self._invalidate_cache()
return True return True
return False return False
def _render_chart(self, state: "SimulationState") -> pygame.Surface: def _render_chart(self, state: "SimulationState") -> pygame.Surface:
"""Render the current tab's chart.""" """Render the current tab's chart."""
tab_name, tab_key = self.TABS[self.current_tab] tab_name, tab_key = self.TABS[self.current_tab]
# Check cache # Check cache
current_turn = state.turn if state else 0 current_turn = state.turn if state else 0
if tab_key in self._chart_cache and self._cache_turn == current_turn: if tab_key in self._chart_cache and self._cache_turn == current_turn:
return self._chart_cache[tab_key] return self._chart_cache[tab_key]
# Render chart based on current tab # Render chart based on current tab
width = self.chart_rect.width width = self.chart_rect.width
height = self.chart_rect.height height = self.chart_rect.height
if tab_key == "price_history": if tab_key == "price_history":
surface = self.chart_renderer.render_price_history(self.history, width, height) surface = self.chart_renderer.render_price_history(self.history, width, height)
elif tab_key == "wealth": elif tab_key == "wealth":
@ -676,7 +676,7 @@ class StatsRenderer:
half_height = height // 2 half_height = height // 2
dist_surface = self.chart_renderer.render_wealth_distribution(state, width, half_height) dist_surface = self.chart_renderer.render_wealth_distribution(state, width, half_height)
time_surface = self.chart_renderer.render_wealth_over_time(self.history, width, half_height) time_surface = self.chart_renderer.render_wealth_over_time(self.history, width, half_height)
surface = pygame.Surface((width, height)) surface = pygame.Surface((width, height))
surface.fill(UIColors.BG) surface.fill(UIColors.BG)
surface.blit(dist_surface, (0, 0)) surface.blit(dist_surface, (0, 0))
@ -693,48 +693,48 @@ class StatsRenderer:
# Fallback empty surface # Fallback empty surface
surface = pygame.Surface((width, height)) surface = pygame.Surface((width, height))
surface.fill(UIColors.BG) surface.fill(UIColors.BG)
# Cache the result # Cache the result
self._chart_cache[tab_key] = surface self._chart_cache[tab_key] = surface
self._cache_turn = current_turn self._cache_turn = current_turn
return surface return surface
def draw(self, state: "SimulationState") -> None: def draw(self, state: "SimulationState") -> None:
"""Draw the statistics panel.""" """Draw the statistics panel."""
if not self.visible: if not self.visible:
return return
# Dim background # Dim background
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 220)) overlay.fill((0, 0, 0, 220))
self.screen.blit(overlay, (0, 0)) self.screen.blit(overlay, (0, 0))
# Panel background # Panel background
pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=12) pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=12)
pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=12) pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=12)
# Draw tabs # Draw tabs
self._draw_tabs() self._draw_tabs()
# Draw chart # Draw chart
if state: if state:
chart_surface = self._render_chart(state) chart_surface = self._render_chart(state)
self.screen.blit(chart_surface, self.chart_rect.topleft) self.screen.blit(chart_surface, self.chart_rect.topleft)
# Draw close hint # Draw close hint
hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs", hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs",
True, UIColors.TEXT_SECONDARY) True, UIColors.TEXT_SECONDARY)
hint_rect = hint.get_rect(centerx=self.panel_rect.centerx, hint_rect = hint.get_rect(centerx=self.panel_rect.centerx,
y=self.panel_rect.bottom - 25) y=self.panel_rect.bottom - 25)
self.screen.blit(hint, hint_rect) self.screen.blit(hint, hint_rect)
def _draw_tabs(self) -> None: def _draw_tabs(self) -> None:
"""Draw the tab bar.""" """Draw the tab bar."""
for i, (tab_name, _) in enumerate(self.TABS): for i, (tab_name, _) in enumerate(self.TABS):
tab_x = self.tab_rect.x + i * self.tab_width tab_x = self.tab_rect.x + i * self.tab_width
tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height) tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height)
# Tab background # Tab background
if i == self.current_tab: if i == self.current_tab:
color = UIColors.TAB_ACTIVE color = UIColors.TAB_ACTIVE
@ -742,26 +742,26 @@ class StatsRenderer:
color = UIColors.TAB_HOVER color = UIColors.TAB_HOVER
else: else:
color = UIColors.TAB_INACTIVE color = UIColors.TAB_INACTIVE
# Draw tab with rounded top corners # Draw tab with rounded top corners
tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA) tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA)
pygame.draw.rect(tab_surface, color, (0, 0, self.tab_width, self.tab_height), pygame.draw.rect(tab_surface, color, (0, 0, self.tab_width, self.tab_height),
border_top_left_radius=8, border_top_right_radius=8) border_top_left_radius=8, border_top_right_radius=8)
if i == self.current_tab: if i == self.current_tab:
# Active tab - solid color # Active tab - solid color
tab_surface.set_alpha(255) tab_surface.set_alpha(255)
else: else:
tab_surface.set_alpha(180) tab_surface.set_alpha(180)
self.screen.blit(tab_surface, (tab_x, self.tab_rect.y)) self.screen.blit(tab_surface, (tab_x, self.tab_rect.y))
# Tab text # Tab text
text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY
text = self.small_font.render(tab_name, True, text_color) text = self.small_font.render(tab_name, True, text_color)
text_rect = text.get_rect(center=tab_rect.center) text_rect = text.get_rect(center=tab_rect.center)
self.screen.blit(text, text_rect) self.screen.blit(text, text_rect)
# Tab border # Tab border
if i != self.current_tab: if i != self.current_tab:
pygame.draw.line(self.screen, UIColors.PANEL_BORDER, pygame.draw.line(self.screen, UIColors.PANEL_BORDER,

496
tools/optimize_goap.py Normal file
View File

@ -0,0 +1,496 @@
#!/usr/bin/env python3
"""
GOAP Economy Optimizer for Village Simulation
This script optimizes the simulation parameters specifically for the GOAP AI system.
The goal is to achieve:
- Balanced action diversity (hunting, gathering, trading)
- Active economy with trading
- Good survival rates
- Meat production through hunting
Key insight: GOAP uses action COSTS to choose actions. Lower cost = preferred.
We need to tune:
1. Action energy costs (config.json)
2. GOAP action cost functions (goap/actions.py)
3. Goal priorities (goap/goals.py)
Usage:
python tools/optimize_goap.py [--iterations 15] [--steps 300]
python tools/optimize_goap.py --analyze # Analyze current GOAP behavior
"""
import argparse
import json
import random
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
# Add parent directory for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.config import get_config, reload_config
from backend.core.engine import GameEngine
from backend.domain.action import reset_action_config_cache
from backend.domain.resources import reset_resource_cache
def analyze_goap_behavior(num_steps: int = 100, num_agents: int = 10):
"""Analyze current GOAP behavior in detail."""
print("\n" + "=" * 70)
print("🔍 GOAP BEHAVIOR ANALYSIS")
print("=" * 70)
# Reset engine
GameEngine._instance = None
engine = GameEngine()
engine.initialize(num_agents=num_agents)
# Track statistics
action_counts = defaultdict(int)
goal_counts = defaultdict(int)
reactive_count = 0
planned_count = 0
# Resource tracking
resources_produced = defaultdict(int)
resources_consumed = defaultdict(int)
# Run simulation
for step in range(num_steps):
if not engine.is_running:
print(f" Simulation ended at step {step}")
break
log = engine.next_step()
for action_data in log.agent_actions:
decision = action_data.get("decision", {})
result = action_data.get("result", {})
action_type = decision.get("action", "unknown")
action_counts[action_type] += 1
# Track goal/reactive
goal_name = decision.get("goal_name", "")
reason = decision.get("reason", "")
if goal_name:
goal_counts[goal_name] += 1
planned_count += 1
elif "Reactive" in reason:
goal_counts["(reactive)"] += 1
reactive_count += 1
# Track resources
if result and result.get("success"):
for res in result.get("resources_gained", []):
resources_produced[res.get("type", "unknown")] += res.get("quantity", 0)
for res in result.get("resources_consumed", []):
resources_consumed[res.get("type", "unknown")] += res.get("quantity", 0)
# Print results
total_actions = sum(action_counts.values())
print(f"\n📊 Action Distribution ({num_steps} turns, {num_agents} agents)")
print("-" * 50)
for action, count in sorted(action_counts.items(), key=lambda x: -x[1]):
pct = count * 100 / total_actions if total_actions > 0 else 0
bar = "" * int(pct / 2)
print(f" {action:12} {count:4} ({pct:5.1f}%) {bar}")
print(f"\n🎯 Goal Distribution")
print("-" * 50)
total_goals = sum(goal_counts.values())
for goal, count in sorted(goal_counts.items(), key=lambda x: -x[1])[:15]:
pct = count * 100 / total_goals if total_goals > 0 else 0
print(f" {goal:20} {count:4} ({pct:5.1f}%)")
print(f"\n Planned actions: {planned_count} ({planned_count*100/total_actions:.1f}%)")
print(f" Reactive actions: {reactive_count} ({reactive_count*100/total_actions:.1f}%)")
print(f"\n📦 Resources Produced")
print("-" * 50)
for res, qty in sorted(resources_produced.items(), key=lambda x: -x[1]):
print(f" {res:12} {qty:4}")
print(f"\n🔥 Resources Consumed")
print("-" * 50)
for res, qty in sorted(resources_consumed.items(), key=lambda x: -x[1]):
print(f" {res:12} {qty:4}")
# Diagnose issues
print(f"\n⚠️ ISSUES DETECTED:")
print("-" * 50)
hunt_pct = action_counts.get("hunt", 0) * 100 / total_actions if total_actions > 0 else 0
gather_pct = action_counts.get("gather", 0) * 100 / total_actions if total_actions > 0 else 0
if hunt_pct < 5:
print(" ❌ Almost no hunting! Hunt action cost too high or meat not valued enough.")
print(" → Reduce hunt energy cost or increase meat benefits")
if resources_produced.get("meat", 0) == 0:
print(" ❌ No meat produced! Agents never hunt successfully.")
trade_pct = action_counts.get("trade", 0) * 100 / total_actions if total_actions > 0 else 0
if trade_pct < 5:
print(" ❌ Low trading activity. Market goals not prioritized.")
if reactive_count > planned_count:
print(" ⚠️ More reactive than planned actions. Goals may be too easily satisfied.")
return {
"action_counts": dict(action_counts),
"goal_counts": dict(goal_counts),
"resources_produced": dict(resources_produced),
"resources_consumed": dict(resources_consumed),
}
def test_config(config_overrides: dict, num_steps: int = 200, num_agents: int = 10, verbose: bool = True):
"""Test a configuration and return metrics."""
# Save original config
config_path = Path("config.json")
with open(config_path) as f:
original_config = json.load(f)
# Apply overrides
test_config = json.loads(json.dumps(original_config))
for section, values in config_overrides.items():
if section in test_config:
test_config[section].update(values)
else:
test_config[section] = values
# Save temp config
temp_path = Path("config_temp.json")
with open(temp_path, 'w') as f:
json.dump(test_config, f, indent=2)
# Reload config
reload_config(str(temp_path))
reset_action_config_cache()
reset_resource_cache()
# Run simulation
GameEngine._instance = None
engine = GameEngine()
engine.initialize(num_agents=num_agents)
action_counts = defaultdict(int)
resources_produced = defaultdict(int)
deaths = 0
trades_completed = 0
for step in range(num_steps):
if not engine.is_running:
break
log = engine.next_step()
deaths += len(log.deaths)
for action_data in log.agent_actions:
decision = action_data.get("decision", {})
result = action_data.get("result", {})
action_type = decision.get("action", "unknown")
action_counts[action_type] += 1
if result and result.get("success"):
for res in result.get("resources_gained", []):
resources_produced[res.get("type", "unknown")] += res.get("quantity", 0)
if action_type == "trade" and "Bought" in result.get("message", ""):
trades_completed += 1
final_pop = len(engine.world.get_living_agents())
# Cleanup
engine.logger.close()
temp_path.unlink(missing_ok=True)
# Restore original config
reload_config(str(config_path))
reset_action_config_cache()
reset_resource_cache()
# Calculate score
total_actions = sum(action_counts.values())
hunt_ratio = action_counts.get("hunt", 0) / total_actions if total_actions > 0 else 0
gather_ratio = action_counts.get("gather", 0) / total_actions if total_actions > 0 else 0
trade_ratio = action_counts.get("trade", 0) / total_actions if total_actions > 0 else 0
survival_rate = final_pop / num_agents
# Score components
# 1. Hunt ratio: want 10-25%
hunt_score = min(25, hunt_ratio * 100) if hunt_ratio > 0.05 else 0
# 2. Trade activity: want 5-15%
trade_score = min(20, trade_ratio * 100 * 2)
# 3. Resource diversity
has_meat = resources_produced.get("meat", 0) > 0
has_berries = resources_produced.get("berries", 0) > 0
has_wood = resources_produced.get("wood", 0) > 0
has_water = resources_produced.get("water", 0) > 0
diversity_score = (int(has_meat) + int(has_berries) + int(has_wood) + int(has_water)) * 5
# 4. Survival
survival_score = survival_rate * 30
# 5. Meat production bonus
meat_score = min(15, resources_produced.get("meat", 0) / 5)
total_score = hunt_score + trade_score + diversity_score + survival_score + meat_score
if verbose:
print(f"\n Score: {total_score:.1f}/100")
print(f" ├─ Hunt: {hunt_ratio*100:.1f}% ({hunt_score:.1f} pts)")
print(f" ├─ Trade: {trade_ratio*100:.1f}% ({trade_score:.1f} pts)")
print(f" ├─ Diversity: {diversity_score:.1f} pts")
print(f" ├─ Survival: {survival_rate*100:.0f}% ({survival_score:.1f} pts)")
print(f" └─ Meat produced: {resources_produced.get('meat', 0)} ({meat_score:.1f} pts)")
print(f" Actions: hunt={action_counts.get('hunt',0)}, gather={action_counts.get('gather',0)}, trade={action_counts.get('trade',0)}")
return {
"score": total_score,
"action_counts": dict(action_counts),
"resources": dict(resources_produced),
"survival_rate": survival_rate,
"deaths": deaths,
}
def optimize_for_goap(iterations: int = 15, steps: int = 300):
"""Run optimization focused on GOAP-specific parameters."""
print("\n" + "=" * 70)
print("🧬 GOAP ECONOMY OPTIMIZER")
print("=" * 70)
print(f" Iterations: {iterations}")
print(f" Steps per test: {steps}")
print("=" * 70)
# Key parameters to optimize for GOAP
# Focus on making hunting more attractive
configs_to_test = [
# Baseline
{
"name": "Baseline (current)",
"config": {}
},
# Cheaper hunting
{
"name": "Cheaper Hunt (-5 energy)",
"config": {
"actions": {
"hunt_energy": -5,
"hunt_success": 0.8,
}
}
},
# More valuable meat
{
"name": "Valuable Meat (+45 hunger)",
"config": {
"resources": {
"meat_hunger": 45,
"meat_energy": 15,
},
"actions": {
"hunt_energy": -6,
"hunt_success": 0.8,
}
}
},
# Make berries less attractive
{
"name": "Nerfed Berries",
"config": {
"resources": {
"meat_hunger": 45,
"meat_energy": 15,
"berries_hunger": 8,
"berries_thirst": 2,
},
"actions": {
"hunt_energy": -5,
"gather_energy": -4,
"hunt_success": 0.85,
"hunt_meat_min": 2,
"hunt_meat_max": 4,
}
}
},
# Higher hunt output
{
"name": "High Hunt Output",
"config": {
"resources": {
"meat_hunger": 40,
"meat_energy": 12,
},
"actions": {
"hunt_energy": -6,
"hunt_success": 0.85,
"hunt_meat_min": 3,
"hunt_meat_max": 6,
"hunt_hide_min": 1,
"hunt_hide_max": 2,
}
}
},
# Balanced economy
{
"name": "Balanced Economy",
"config": {
"resources": {
"meat_hunger": 40,
"meat_energy": 15,
"berries_hunger": 8,
},
"actions": {
"hunt_energy": -5,
"gather_energy": -4,
"hunt_success": 0.8,
"hunt_meat_min": 2,
"hunt_meat_max": 5,
},
"economy": {
"buy_efficiency_threshold": 0.9,
"min_wealth_target": 40,
}
}
},
# Pro-hunting config
{
"name": "Pro-Hunting",
"config": {
"agent_stats": {
"hunger_decay": 3, # Higher hunger decay = need more food
},
"resources": {
"meat_hunger": 50, # Meat is very filling
"meat_energy": 15,
"berries_hunger": 6, # Berries less filling
},
"actions": {
"hunt_energy": -4, # Very cheap to hunt
"gather_energy": -4,
"hunt_success": 0.85,
"hunt_meat_min": 3,
"hunt_meat_max": 5,
}
}
},
# Full rebalance
{
"name": "Full Rebalance",
"config": {
"agent_stats": {
"start_hunger": 70,
"hunger_decay": 3,
"thirst_decay": 3,
},
"resources": {
"meat_hunger": 50,
"meat_energy": 15,
"berries_hunger": 8,
"berries_thirst": 3,
"water_thirst": 45,
},
"actions": {
"hunt_energy": -5,
"gather_energy": -4,
"chop_wood_energy": -5,
"get_water_energy": -3,
"hunt_success": 0.8,
"hunt_meat_min": 2,
"hunt_meat_max": 5,
"hunt_hide_min": 0,
"hunt_hide_max": 1,
"gather_min": 2,
"gather_max": 3,
}
}
},
]
best_config = None
best_score = 0
best_name = ""
for cfg in configs_to_test:
print(f"\n🧪 Testing: {cfg['name']}")
print("-" * 50)
result = test_config(cfg["config"], steps, verbose=True)
if result["score"] > best_score:
best_score = result["score"]
best_config = cfg["config"]
best_name = cfg["name"]
print(f" ⭐ New best!")
print("\n" + "=" * 70)
print("🏆 OPTIMIZATION COMPLETE")
print("=" * 70)
print(f"\n Best Config: {best_name}")
print(f" Best Score: {best_score:.1f}/100")
if best_config:
print("\n 📝 Configuration to apply:")
print("-" * 50)
print(json.dumps(best_config, indent=2))
# Ask to apply
print("\n Would you like to apply this configuration? (y/n)")
# Save as optimized config
output_path = Path("config_goap_optimized.json")
with open("config.json") as f:
full_config = json.load(f)
for section, values in best_config.items():
if section in full_config:
full_config[section].update(values)
else:
full_config[section] = values
with open(output_path, 'w') as f:
json.dump(full_config, f, indent=2)
print(f"\n ✅ Saved to: {output_path}")
print(" To apply: cp config_goap_optimized.json config.json")
return best_config
def main():
parser = argparse.ArgumentParser(description="Optimize GOAP economy parameters")
parser.add_argument("--analyze", "-a", action="store_true", help="Analyze current behavior")
parser.add_argument("--iterations", "-i", type=int, default=15, help="Optimization iterations")
parser.add_argument("--steps", "-s", type=int, default=200, help="Steps per simulation")
parser.add_argument("--apply", action="store_true", help="Auto-apply best config")
args = parser.parse_args()
if args.analyze:
analyze_goap_behavior(args.steps)
else:
best = optimize_for_goap(args.iterations, args.steps)
if args.apply and best:
# Apply the config
import shutil
shutil.copy("config_goap_optimized.json", "config.json")
print("\n ✅ Configuration applied!")
if __name__ == "__main__":
main()

View File

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

View File

@ -135,6 +135,7 @@
<button class="tab-btn" data-tab="resources">Resources</button> <button class="tab-btn" data-tab="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">
@ -240,8 +241,54 @@
</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>
@ -268,6 +315,11 @@
<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>

View File

@ -124,6 +124,21 @@ 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

View File

@ -127,7 +127,22 @@ 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() {
@ -163,6 +178,21 @@ 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 = {};
@ -277,19 +307,34 @@ 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);
@ -297,6 +342,12 @@ 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));
@ -371,15 +422,19 @@ export default class GameScene extends Phaser.Scene {
toggleAutoMode() { toggleAutoMode() {
this.isAutoMode = !this.isAutoMode; this.isAutoMode = !this.isAutoMode;
const { btnAuto, btnStep } = this.domCache; const { btnAuto, btnStep, btnAutoStats, btnStepStats } = 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();
} }
} }
@ -741,6 +796,15 @@ 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()}
@ -1023,6 +1087,7 @@ 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;
} }
} }
@ -1627,6 +1692,291 @@ export default class GameScene extends Phaser.Scene {
}; };
} }
// =================================
// GOAP Visualization Methods
// =================================
async loadAgentGOAP(agentId) {
const contentEl = document.getElementById('agent-goap-content');
if (!contentEl) return;
contentEl.innerHTML = '<span style="color: var(--text-muted);">Loading...</span>';
try {
const data = await api.getAgentGOAPDebug(agentId);
const plan = data.current_plan;
if (plan && plan.actions.length > 0) {
contentEl.innerHTML = `
<div style="margin-bottom: 4px;">
<strong style="color: var(--accent-sapphire);">Goal:</strong> ${plan.goal_name}
</div>
<div style="font-family: var(--font-mono); font-size: 0.7rem;">
${plan.actions.map((a, i) =>
`<span style="${i === 0 ? 'color: var(--accent-emerald);' : ''}">${a}</span>`
).join(' → ')}
</div>
<div style="margin-top: 4px; color: var(--text-muted); font-size: 0.65rem;">
Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
</div>
`;
} else {
contentEl.innerHTML = `
<div style="color: var(--text-muted);">
No plan (reactive mode)<br>
<span style="color: var(--text-primary);">${data.selected_action || 'No action'}</span>
</div>
`;
}
} catch (error) {
console.error('Failed to load GOAP info:', error);
contentEl.innerHTML = '<span style="color: var(--accent-ruby);">Failed to load</span>';
}
}
async fetchAndRenderGOAP() {
try {
const response = await api.get('/goap/debug');
this.goapData = response;
this.renderGOAPAgentList();
// If we have a selected agent, render their details
if (this.selectedGoapAgentId) {
const agent = this.goapData.agents.find(a => a.agent_id === this.selectedGoapAgentId);
if (agent) {
this.renderGOAPAgentDetails(agent);
}
}
} catch (error) {
console.error('Failed to fetch GOAP data:', error);
const { goapAgentList } = this.domCache;
if (goapAgentList) {
goapAgentList.innerHTML = '<p class="loading-text">Failed to load GOAP data. Make sure the server is running.</p>';
}
}
}
renderGOAPAgentList() {
const { goapAgentList } = this.domCache;
if (!goapAgentList || !this.goapData) return;
if (this.goapData.agents.length === 0) {
goapAgentList.innerHTML = '<p class="loading-text">No agents found</p>';
return;
}
goapAgentList.innerHTML = this.goapData.agents.map(agent => `
<div class="goap-agent-item ${agent.agent_id === this.selectedGoapAgentId ? 'selected' : ''}"
data-agent-id="${agent.agent_id}">
<div class="agent-name">${agent.agent_name}</div>
<div class="agent-action">${agent.selected_action || 'No action'}</div>
<div class="agent-goal">${agent.current_plan ? '🎯 ' + agent.current_plan.goal_name : '(reactive)'}</div>
</div>
`).join('');
// Add click handlers
goapAgentList.querySelectorAll('.goap-agent-item').forEach(item => {
item.addEventListener('click', () => {
this.selectGoapAgent(item.dataset.agentId);
});
});
}
selectGoapAgent(agentId) {
this.selectedGoapAgentId = agentId;
// Update selection styling
const { goapAgentList } = this.domCache;
if (goapAgentList) {
goapAgentList.querySelectorAll('.goap-agent-item').forEach(item => {
item.classList.toggle('selected', item.dataset.agentId === agentId);
});
}
// Render details
if (this.goapData) {
const agent = this.goapData.agents.find(a => a.agent_id === agentId);
if (agent) {
this.renderGOAPAgentDetails(agent);
}
}
}
renderGOAPAgentDetails(agent) {
this.renderGOAPPlanView(agent);
this.renderGOAPActionsList(agent);
this.renderGOAPGoalsChart(agent);
}
getUrgencyClass(urgency) {
if (urgency <= 0) return 'none';
if (urgency <= 1) return 'low';
return 'high';
}
renderGOAPPlanView(agent) {
const { goapPlanView } = this.domCache;
if (!goapPlanView) return;
const ws = agent.world_state;
const plan = agent.current_plan;
goapPlanView.innerHTML = `
<div class="goap-world-state">
<div class="goap-stat-card thirst">
<div class="label">Thirst</div>
<div class="value">${Math.round(ws.vitals.thirst * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.thirst)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.thirst * 100}%"></div></div>
</div>
<div class="goap-stat-card hunger">
<div class="label">Hunger</div>
<div class="value">${Math.round(ws.vitals.hunger * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.hunger)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.hunger * 100}%"></div></div>
</div>
<div class="goap-stat-card heat">
<div class="label">Heat</div>
<div class="value">${Math.round(ws.vitals.heat * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.heat)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.heat * 100}%"></div></div>
</div>
<div class="goap-stat-card energy">
<div class="label">Energy</div>
<div class="value">${Math.round(ws.vitals.energy * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.energy)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.energy * 100}%"></div></div>
</div>
</div>
<div class="goap-plan-steps">
<h5>Current Plan</h5>
${plan && plan.actions.length > 0 ? `
<div class="goap-plan-flow">
${plan.actions.map((action, i) => `
<span class="goap-step-node ${i === 0 ? 'current' : ''}">${action}</span>
${i < plan.actions.length - 1 ? '<span class="goap-step-arrow">→</span>' : ''}
`).join('')}
<span class="goap-step-arrow"></span>
<span class="goap-goal-result"> ${plan.goal_name}</span>
</div>
<div style="margin-top: 8px; font-size: 0.75rem; color: var(--text-muted);">
Total Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
</div>
` : `
<div style="color: var(--text-muted); font-size: 0.85rem;">
No plan - using reactive selection<br>
Selected: <strong>${agent.selected_action || 'None'}</strong>
</div>
`}
</div>
<div style="margin-top: 12px;">
<h5 style="font-size: 0.7rem; color: var(--text-muted); margin-bottom: 8px;">INVENTORY</h5>
<div class="goap-inventory">
<div class="goap-inv-item">💧<span class="count">${ws.inventory.water}</span></div>
<div class="goap-inv-item">🍖<span class="count">${ws.inventory.meat}</span></div>
<div class="goap-inv-item">🫐<span class="count">${ws.inventory.berries}</span></div>
<div class="goap-inv-item">🪵<span class="count">${ws.inventory.wood}</span></div>
<div class="goap-inv-item">🥩<span class="count">${ws.inventory.hide}</span></div>
<div class="goap-inv-item">📦<span class="count">${ws.inventory.space}</span></div>
</div>
</div>
<div style="margin-top: 12px; display: flex; gap: 16px; font-size: 0.8rem;">
<span style="color: var(--accent-gold);">💰 ${ws.economy.money}c</span>
<span style="color: var(--text-muted);">Wealthy: ${ws.economy.is_wealthy ? '✓' : '✗'}</span>
</div>
`;
}
renderGOAPActionsList(agent) {
const { goapActionsList } = this.domCache;
if (!goapActionsList) return;
// Sort: plan actions first, then valid, then invalid
const sortedActions = [...agent.actions].sort((a, b) => {
if (a.is_in_plan && !b.is_in_plan) return -1;
if (!a.is_in_plan && b.is_in_plan) return 1;
if (a.is_in_plan && b.is_in_plan) return a.plan_order - b.plan_order;
if (a.is_valid && !b.is_valid) return -1;
if (!a.is_valid && b.is_valid) return 1;
return (a.cost || 999) - (b.cost || 999);
});
goapActionsList.innerHTML = sortedActions.map(action => `
<div class="goap-action-item ${action.is_valid ? 'valid' : 'invalid'} ${action.is_in_plan ? 'in-plan' : ''}">
${action.is_in_plan ? `<span class="action-order">${action.plan_order + 1}</span>` : ''}
<span class="action-name">${action.name}</span>
<span class="action-cost">${action.cost >= 0 ? action.cost.toFixed(1) : '∞'}</span>
</div>
`).join('');
}
renderGOAPGoalsChart(agent) {
const { chartGoapGoals } = this.domCache;
if (!chartGoapGoals) return;
// Sort goals by priority and take top 10
const sortedGoals = [...agent.goals]
.sort((a, b) => b.priority - a.priority)
.slice(0, 10);
// Destroy existing chart
if (this.charts.goapGoals) {
this.charts.goapGoals.destroy();
}
this.charts.goapGoals = new Chart(chartGoapGoals, {
type: 'bar',
data: {
labels: sortedGoals.map(g => g.name),
datasets: [{
label: 'Priority',
data: sortedGoals.map(g => g.priority),
backgroundColor: sortedGoals.map(g => {
if (g.is_selected) return 'rgba(139, 111, 192, 0.8)';
if (g.is_satisfied) return 'rgba(74, 156, 109, 0.5)';
if (g.priority > 0) return 'rgba(90, 140, 200, 0.7)';
return 'rgba(107, 101, 96, 0.3)';
}),
borderColor: sortedGoals.map(g => {
if (g.is_selected) return '#8b6fc0';
if (g.is_satisfied) return '#4a9c6d';
if (g.priority > 0) return '#5a8cc8';
return '#6b6560';
}),
borderWidth: 2,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
title: {
display: true,
text: 'Goal Priorities',
color: '#e8e4dc',
font: { family: "'Crimson Pro', serif", size: 14 },
},
},
scales: {
x: {
beginAtZero: true,
grid: { color: 'rgba(58, 67, 89, 0.3)' },
ticks: { color: '#6b6560' },
},
y: {
grid: { display: false },
ticks: {
color: '#e8e4dc',
font: { family: "'JetBrains Mono', monospace", size: 10 }
},
}
}
}
});
}
update(time, delta) { update(time, delta) {
// Minimal update loop - no heavy operations here // Minimal update loop - no heavy operations here
} }

View File

@ -613,7 +613,8 @@ 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;
@ -623,7 +624,8 @@ 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;
@ -633,7 +635,8 @@ 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;
} }
@ -939,16 +942,21 @@ 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 {
@ -1049,5 +1057,375 @@ 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;
}
} }