Enhance Village Simulation with religion and diplomacy systems, introducing diverse agent beliefs and faction dynamics. Updated configuration parameters for agent stats, resource decay, and economic interactions. Implemented new actions related to religion and diplomacy, including praying, preaching, and negotiating. Improved UI for displaying religious and diplomatic information, and added tools for testing and optimizing balance in these new systems.
This commit is contained in:
parent
1423fc0dc9
commit
cfd6c87f86
@ -28,10 +28,12 @@ class StatsSchema(BaseModel):
|
|||||||
hunger: int
|
hunger: int
|
||||||
thirst: int
|
thirst: int
|
||||||
heat: int
|
heat: int
|
||||||
|
faith: int = 50
|
||||||
max_energy: int
|
max_energy: int
|
||||||
max_hunger: int
|
max_hunger: int
|
||||||
max_thirst: int
|
max_thirst: int
|
||||||
max_heat: int
|
max_heat: int
|
||||||
|
max_faith: int = 100
|
||||||
|
|
||||||
|
|
||||||
class AgentActionSchema(BaseModel):
|
class AgentActionSchema(BaseModel):
|
||||||
@ -44,6 +46,28 @@ class AgentActionSchema(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReligionSchema(BaseModel):
|
||||||
|
"""Schema for agent religion data."""
|
||||||
|
religion: str
|
||||||
|
faith: int
|
||||||
|
is_zealot: bool = False
|
||||||
|
times_converted: int = 0
|
||||||
|
converts_made: int = 0
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DiplomacySchema(BaseModel):
|
||||||
|
"""Schema for agent diplomacy data."""
|
||||||
|
faction: str
|
||||||
|
faction_description: str = ""
|
||||||
|
faction_color: str = "#808080"
|
||||||
|
diplomacy_skill: float = 0.5
|
||||||
|
aggression: float = 0.3
|
||||||
|
negotiations_conducted: int = 0
|
||||||
|
wars_declared: int = 0
|
||||||
|
peace_treaties_made: int = 0
|
||||||
|
|
||||||
|
|
||||||
class AgentResponse(BaseModel):
|
class AgentResponse(BaseModel):
|
||||||
"""Schema for agent data."""
|
"""Schema for agent data."""
|
||||||
id: str
|
id: str
|
||||||
@ -58,6 +82,9 @@ class AgentResponse(BaseModel):
|
|||||||
can_act: bool
|
can_act: bool
|
||||||
current_action: AgentActionSchema
|
current_action: AgentActionSchema
|
||||||
last_action_result: str
|
last_action_result: str
|
||||||
|
# Religion and diplomacy
|
||||||
|
religion: Optional[ReligionSchema] = None
|
||||||
|
diplomacy: Optional[DiplomacySchema] = None
|
||||||
|
|
||||||
|
|
||||||
# ============== Market Schemas ==============
|
# ============== Market Schemas ==============
|
||||||
|
|||||||
@ -12,41 +12,49 @@ class AgentStatsConfig:
|
|||||||
# Maximum values
|
# Maximum values
|
||||||
max_energy: int = 50
|
max_energy: int = 50
|
||||||
max_hunger: int = 100
|
max_hunger: int = 100
|
||||||
max_thirst: int = 100 # Increased from 50 to give more buffer
|
max_thirst: int = 100
|
||||||
max_heat: int = 100
|
max_heat: int = 100
|
||||||
|
max_faith: int = 100 # NEW: Religious faith level
|
||||||
|
|
||||||
# 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
|
||||||
start_heat: int = 100
|
start_heat: int = 100
|
||||||
|
start_faith: int = 50 # NEW: Start with moderate faith
|
||||||
|
|
||||||
# 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
|
||||||
heat_decay: int = 2
|
heat_decay: int = 2
|
||||||
|
faith_decay: int = 1 # NEW: Faith decays slowly without religious activity
|
||||||
|
|
||||||
# Thresholds
|
# Thresholds
|
||||||
critical_threshold: float = 0.25 # 25% triggers survival mode
|
critical_threshold: float = 0.25
|
||||||
low_energy_threshold: int = 15 # Minimum energy to work
|
low_energy_threshold: int = 15
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResourceConfig:
|
class ResourceConfig:
|
||||||
"""Configuration for resource properties."""
|
"""Configuration for resource properties."""
|
||||||
# Decay rates (turns until spoilage, 0 = infinite)
|
# Decay rates (turns until spoilage, 0 = infinite)
|
||||||
meat_decay: int = 8 # Increased from 5 to give more time to use
|
meat_decay: int = 8
|
||||||
berries_decay: int = 25
|
berries_decay: int = 25
|
||||||
clothes_decay: int = 50
|
clothes_decay: int = 50
|
||||||
|
oil_decay: int = 0 # NEW: Oil doesn't decay
|
||||||
|
fuel_decay: int = 0 # NEW: Refined fuel doesn't decay
|
||||||
|
|
||||||
# Resource effects
|
# Resource effects
|
||||||
meat_hunger: int = 30
|
meat_hunger: int = 30
|
||||||
meat_energy: int = 5
|
meat_energy: int = 5
|
||||||
berries_hunger: int = 8 # Increased from 5
|
berries_hunger: int = 8
|
||||||
berries_thirst: int = 3 # Increased from 2
|
berries_thirst: int = 3
|
||||||
water_thirst: int = 50 # Increased from 40 for better thirst recovery
|
water_thirst: int = 50
|
||||||
fire_heat: int = 15 # Increased from 10
|
fire_heat: int = 15
|
||||||
|
fuel_heat: int = 35 # NEW: Fuel provides more heat than wood
|
||||||
|
oil_energy: int = 0 # NEW: Raw oil has no direct use
|
||||||
|
fuel_energy: int = 8 # NEW: Refined fuel provides energy
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -63,9 +71,23 @@ class ActionConfig:
|
|||||||
build_fire_energy: int = -5
|
build_fire_energy: int = -5
|
||||||
trade_energy: int = -1
|
trade_energy: int = -1
|
||||||
|
|
||||||
|
# NEW: Oil industry actions
|
||||||
|
drill_oil_energy: int = -10
|
||||||
|
refine_energy: int = -8
|
||||||
|
|
||||||
|
# NEW: Religious actions
|
||||||
|
pray_energy: int = -2
|
||||||
|
preach_energy: int = -4
|
||||||
|
|
||||||
|
# NEW: Diplomatic actions
|
||||||
|
negotiate_energy: int = -3
|
||||||
|
declare_war_energy: int = -5
|
||||||
|
make_peace_energy: int = -3
|
||||||
|
|
||||||
# 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
|
||||||
|
drill_oil_success: float = 0.6 # NEW: Harder to extract oil
|
||||||
|
|
||||||
# Output quantities
|
# Output quantities
|
||||||
hunt_meat_min: int = 1
|
hunt_meat_min: int = 1
|
||||||
@ -77,6 +99,15 @@ class ActionConfig:
|
|||||||
chop_wood_min: int = 1
|
chop_wood_min: int = 1
|
||||||
chop_wood_max: int = 2
|
chop_wood_max: int = 2
|
||||||
|
|
||||||
|
# NEW: Oil output
|
||||||
|
drill_oil_min: int = 1
|
||||||
|
drill_oil_max: int = 3
|
||||||
|
|
||||||
|
# NEW: Religious action effects
|
||||||
|
pray_faith_gain: int = 25
|
||||||
|
preach_faith_spread: int = 15
|
||||||
|
preach_convert_chance: float = 0.15
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorldConfig:
|
class WorldConfig:
|
||||||
@ -91,41 +122,58 @@ class WorldConfig:
|
|||||||
inventory_slots: int = 10
|
inventory_slots: int = 10
|
||||||
starting_money: int = 100
|
starting_money: int = 100
|
||||||
|
|
||||||
|
# NEW: World features
|
||||||
|
oil_fields_count: int = 3 # Number of oil field locations
|
||||||
|
temple_count: int = 2 # Number of temple/religious locations
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MarketConfig:
|
class MarketConfig:
|
||||||
"""Configuration for market behavior."""
|
"""Configuration for market behavior."""
|
||||||
turns_before_discount: int = 3
|
turns_before_discount: int = 3
|
||||||
discount_rate: float = 0.15 # 15% discount after waiting
|
discount_rate: float = 0.15
|
||||||
base_price_multiplier: float = 1.2 # Markup over production cost
|
base_price_multiplier: float = 1.2
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EconomyConfig:
|
class EconomyConfig:
|
||||||
"""Configuration for economic behavior and agent trading.
|
"""Configuration for economic behavior and agent trading."""
|
||||||
|
energy_to_money_ratio: float = 1.5
|
||||||
These values control how agents perceive the value of money and trading.
|
|
||||||
Higher values make agents more trade-oriented.
|
|
||||||
"""
|
|
||||||
# How much agents value money vs energy
|
|
||||||
# Higher = agents see money as more valuable (trade more)
|
|
||||||
energy_to_money_ratio: float = 1.5 # 1 energy ≈ 1.5 coins
|
|
||||||
|
|
||||||
# How strongly agents desire wealth (0-1)
|
|
||||||
# Higher = agents will prioritize building wealth
|
|
||||||
wealth_desire: float = 0.3
|
wealth_desire: float = 0.3
|
||||||
|
|
||||||
# Buy efficiency threshold (0-1)
|
|
||||||
# If market price < (threshold * fair_value), buy instead of gather
|
|
||||||
# 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
|
|
||||||
min_wealth_target: int = 50
|
min_wealth_target: int = 50
|
||||||
|
max_price_markup: float = 2.0
|
||||||
|
min_price_discount: float = 0.5
|
||||||
|
|
||||||
# Price adjustment limits
|
# NEW: Oil economy
|
||||||
max_price_markup: float = 2.0 # Maximum price = 2x base value
|
oil_base_price: int = 25 # Oil is valuable
|
||||||
min_price_discount: float = 0.5 # Minimum price = 50% of base value
|
fuel_base_price: int = 40 # Refined fuel is more valuable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReligionConfig:
|
||||||
|
"""Configuration for religion system."""
|
||||||
|
num_religions: int = 3 # Number of different religions
|
||||||
|
conversion_resistance: float = 0.5 # How hard to convert agents
|
||||||
|
zealot_threshold: float = 0.80 # Faith level for zealot behavior
|
||||||
|
faith_trade_bonus: float = 0.10 # Bonus when trading with same religion
|
||||||
|
same_religion_bonus: float = 0.15 # General bonus with same religion
|
||||||
|
different_religion_penalty: float = 0.10 # Penalty with different religion
|
||||||
|
holy_war_threshold: float = 0.90 # Faith level to trigger religious conflict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiplomacyConfig:
|
||||||
|
"""Configuration for diplomacy and faction system."""
|
||||||
|
num_factions: int = 4 # Number of factions
|
||||||
|
starting_relations: int = 50 # Neutral starting relations (0-100)
|
||||||
|
alliance_threshold: int = 75 # Relations needed for alliance
|
||||||
|
war_threshold: int = 25 # Relations below this = hostile
|
||||||
|
relation_decay: int = 1 # Relations decay towards neutral
|
||||||
|
trade_relation_boost: int = 2 # Trading improves relations
|
||||||
|
war_damage_multiplier: float = 1.5 # Extra damage during war
|
||||||
|
peace_treaty_duration: int = 20 # Turns peace treaty lasts
|
||||||
|
war_exhaustion_rate: int = 2 # How fast war exhaustion builds
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -137,9 +185,11 @@ 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)
|
||||||
|
religion: ReligionConfig = field(default_factory=ReligionConfig) # NEW
|
||||||
|
diplomacy: DiplomacyConfig = field(default_factory=DiplomacyConfig) # NEW
|
||||||
|
|
||||||
# Simulation control
|
# Simulation control
|
||||||
auto_step_interval: float = 1.0 # Seconds between auto steps
|
auto_step_interval: float = 1.0
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary."""
|
"""Convert to dictionary."""
|
||||||
@ -150,6 +200,8 @@ class SimulationConfig:
|
|||||||
"world": asdict(self.world),
|
"world": asdict(self.world),
|
||||||
"market": asdict(self.market),
|
"market": asdict(self.market),
|
||||||
"economy": asdict(self.economy),
|
"economy": asdict(self.economy),
|
||||||
|
"religion": asdict(self.religion),
|
||||||
|
"diplomacy": asdict(self.diplomacy),
|
||||||
"auto_step_interval": self.auto_step_interval,
|
"auto_step_interval": self.auto_step_interval,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,6 +215,8 @@ class SimulationConfig:
|
|||||||
world=WorldConfig(**data.get("world", {})),
|
world=WorldConfig(**data.get("world", {})),
|
||||||
market=MarketConfig(**data.get("market", {})),
|
market=MarketConfig(**data.get("market", {})),
|
||||||
economy=EconomyConfig(**data.get("economy", {})),
|
economy=EconomyConfig(**data.get("economy", {})),
|
||||||
|
religion=ReligionConfig(**data.get("religion", {})),
|
||||||
|
diplomacy=DiplomacyConfig(**data.get("diplomacy", {})),
|
||||||
auto_step_interval=data.get("auto_step_interval", 1.0),
|
auto_step_interval=data.get("auto_step_interval", 1.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -179,7 +233,7 @@ class SimulationConfig:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return cls.from_dict(data)
|
return cls.from_dict(data)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return cls() # Return defaults if file not found
|
return cls()
|
||||||
|
|
||||||
|
|
||||||
# Global configuration instance
|
# Global configuration instance
|
||||||
@ -187,10 +241,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.
|
|
||||||
"""
|
|
||||||
global _config
|
global _config
|
||||||
if _config is None:
|
if _config is None:
|
||||||
_config = load_config()
|
_config = load_config()
|
||||||
@ -202,8 +253,6 @@ def load_config(path: str = "config.json") -> SimulationConfig:
|
|||||||
try:
|
try:
|
||||||
config_path = Path(path)
|
config_path = Path(path)
|
||||||
if not config_path.is_absolute():
|
if not config_path.is_absolute():
|
||||||
# Try relative to workspace root (villsim/)
|
|
||||||
# __file__ is backend/config.py, so .parent.parent is villsim/
|
|
||||||
workspace_root = Path(__file__).parent.parent
|
workspace_root = Path(__file__).parent.parent
|
||||||
config_path = workspace_root / path
|
config_path = workspace_root / path
|
||||||
|
|
||||||
@ -214,7 +263,7 @@ def load_config(path: str = "config.json") -> SimulationConfig:
|
|||||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
print(f"Warning: Could not load config from {path}: {e}")
|
print(f"Warning: Could not load config from {path}: {e}")
|
||||||
|
|
||||||
return SimulationConfig() # Return defaults if file not found
|
return SimulationConfig()
|
||||||
|
|
||||||
|
|
||||||
def set_config(config: SimulationConfig) -> None:
|
def set_config(config: SimulationConfig) -> None:
|
||||||
@ -252,4 +301,3 @@ def _reset_all_caches() -> None:
|
|||||||
reset_resource_cache()
|
reset_resource_cache()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,10 @@
|
|||||||
"""Game Engine for the Village Simulation."""
|
"""Game Engine for the Village Simulation.
|
||||||
|
|
||||||
|
Now includes support for:
|
||||||
|
- Oil industry (drill_oil, refine, burn_fuel)
|
||||||
|
- Religion (pray, preach)
|
||||||
|
- Diplomacy (negotiate, declare_war, make_peace)
|
||||||
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import threading
|
import threading
|
||||||
@ -9,8 +15,12 @@ from typing import Optional
|
|||||||
|
|
||||||
from backend.domain.agent import Agent
|
from backend.domain.agent import Agent
|
||||||
from backend.domain.action import ActionType, ActionResult, ACTION_CONFIG
|
from backend.domain.action import ActionType, ActionResult, ACTION_CONFIG
|
||||||
from backend.domain.resources import Resource, ResourceType
|
from backend.domain.resources import Resource, ResourceType, get_fire_heat, get_fuel_heat
|
||||||
from backend.domain.personality import get_action_skill_modifier
|
from backend.domain.personality import get_action_skill_modifier
|
||||||
|
from backend.domain.religion import get_religion_action_bonus
|
||||||
|
from backend.domain.diplomacy import (
|
||||||
|
FactionType, get_faction_relations, reset_faction_relations
|
||||||
|
)
|
||||||
from backend.core.world import World, WorldConfig, TimeOfDay
|
from backend.core.world import World, WorldConfig, TimeOfDay
|
||||||
from backend.core.market import OrderBook
|
from backend.core.market import OrderBook
|
||||||
from backend.core.ai import get_ai_decision, AIDecision
|
from backend.core.ai import get_ai_decision, AIDecision
|
||||||
@ -20,8 +30,8 @@ from backend.config import get_config
|
|||||||
|
|
||||||
class SimulationMode(Enum):
|
class SimulationMode(Enum):
|
||||||
"""Simulation run mode."""
|
"""Simulation run mode."""
|
||||||
MANUAL = "manual" # Wait for explicit next_step call
|
MANUAL = "manual"
|
||||||
AUTO = "auto" # Run automatically with timer
|
AUTO = "auto"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -31,6 +41,8 @@ class TurnLog:
|
|||||||
agent_actions: list[dict] = field(default_factory=list)
|
agent_actions: list[dict] = field(default_factory=list)
|
||||||
deaths: list[str] = field(default_factory=list)
|
deaths: list[str] = field(default_factory=list)
|
||||||
trades: list[dict] = field(default_factory=list)
|
trades: list[dict] = field(default_factory=list)
|
||||||
|
religious_events: list[dict] = field(default_factory=list) # NEW
|
||||||
|
diplomatic_events: list[dict] = field(default_factory=list) # NEW
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@ -38,6 +50,8 @@ class TurnLog:
|
|||||||
"agent_actions": self.agent_actions,
|
"agent_actions": self.agent_actions,
|
||||||
"deaths": self.deaths,
|
"deaths": self.deaths,
|
||||||
"trades": self.trades,
|
"trades": self.trades,
|
||||||
|
"religious_events": self.religious_events,
|
||||||
|
"diplomatic_events": self.diplomatic_events,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -60,7 +74,6 @@ class GameEngine:
|
|||||||
self.market = OrderBook()
|
self.market = OrderBook()
|
||||||
self.mode = SimulationMode.MANUAL
|
self.mode = SimulationMode.MANUAL
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
# Load auto_step_interval from config
|
|
||||||
self.auto_step_interval = get_config().auto_step_interval
|
self.auto_step_interval = get_config().auto_step_interval
|
||||||
self._auto_thread: Optional[threading.Thread] = None
|
self._auto_thread: Optional[threading.Thread] = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
@ -70,9 +83,11 @@ class GameEngine:
|
|||||||
|
|
||||||
def reset(self, config: Optional[WorldConfig] = None) -> None:
|
def reset(self, config: Optional[WorldConfig] = None) -> None:
|
||||||
"""Reset the simulation to initial state."""
|
"""Reset the simulation to initial state."""
|
||||||
# Stop auto mode if running
|
|
||||||
self._stop_auto_mode()
|
self._stop_auto_mode()
|
||||||
|
|
||||||
|
# Reset faction relations
|
||||||
|
reset_faction_relations()
|
||||||
|
|
||||||
if config:
|
if config:
|
||||||
self.world = World(config=config)
|
self.world = World(config=config)
|
||||||
else:
|
else:
|
||||||
@ -80,7 +95,6 @@ class GameEngine:
|
|||||||
self.market = OrderBook()
|
self.market = OrderBook()
|
||||||
self.turn_logs = []
|
self.turn_logs = []
|
||||||
|
|
||||||
# Reset and start new logging session
|
|
||||||
self.logger = reset_simulation_logger()
|
self.logger = reset_simulation_logger()
|
||||||
sim_config = get_config()
|
sim_config = get_config()
|
||||||
self.logger.start_session(sim_config.to_dict())
|
self.logger.start_session(sim_config.to_dict())
|
||||||
@ -89,18 +103,15 @@ class GameEngine:
|
|||||||
self.is_running = True
|
self.is_running = True
|
||||||
|
|
||||||
def initialize(self, num_agents: Optional[int] = None) -> None:
|
def initialize(self, num_agents: Optional[int] = None) -> None:
|
||||||
"""Initialize the simulation with agents.
|
"""Initialize the simulation with agents."""
|
||||||
|
# Reset faction relations
|
||||||
|
reset_faction_relations()
|
||||||
|
|
||||||
Args:
|
|
||||||
num_agents: Number of agents to spawn. If None, uses config.json value.
|
|
||||||
"""
|
|
||||||
if num_agents is not None:
|
if num_agents is not None:
|
||||||
self.world.config.initial_agents = num_agents
|
self.world.config.initial_agents = num_agents
|
||||||
# Otherwise use the value already loaded from config.json
|
|
||||||
|
|
||||||
self.world.initialize()
|
self.world.initialize()
|
||||||
|
|
||||||
# Start logging session
|
|
||||||
self.logger = reset_simulation_logger()
|
self.logger = reset_simulation_logger()
|
||||||
sim_config = get_config()
|
sim_config = get_config()
|
||||||
self.logger.start_session(sim_config.to_dict())
|
self.logger.start_session(sim_config.to_dict())
|
||||||
@ -115,7 +126,6 @@ class GameEngine:
|
|||||||
turn_log = TurnLog(turn=self.world.current_turn + 1)
|
turn_log = TurnLog(turn=self.world.current_turn + 1)
|
||||||
current_turn = self.world.current_turn + 1
|
current_turn = self.world.current_turn + 1
|
||||||
|
|
||||||
# Start logging this turn
|
|
||||||
self.logger.start_turn(
|
self.logger.start_turn(
|
||||||
turn=current_turn,
|
turn=current_turn,
|
||||||
day=self.world.current_day,
|
day=self.world.current_day,
|
||||||
@ -123,16 +133,14 @@ class GameEngine:
|
|||||||
time_of_day=self.world.time_of_day.value,
|
time_of_day=self.world.time_of_day.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log market state before
|
|
||||||
market_orders_before = [o.to_dict() for o in self.market.get_active_orders()]
|
market_orders_before = [o.to_dict() for o in self.market.get_active_orders()]
|
||||||
|
|
||||||
# 0. Remove corpses from previous turn (agents who died last turn)
|
# Remove old corpses
|
||||||
self._remove_old_corpses(current_turn)
|
self._remove_old_corpses(current_turn)
|
||||||
|
|
||||||
# 1. Collect AI decisions for all living agents (not corpses)
|
# Collect AI decisions
|
||||||
decisions: list[tuple[Agent, AIDecision]] = []
|
decisions: list[tuple[Agent, AIDecision]] = []
|
||||||
for agent in self.world.get_living_agents():
|
for agent in self.world.get_living_agents():
|
||||||
# Log agent state before
|
|
||||||
self.logger.log_agent_before(
|
self.logger.log_agent_before(
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
agent_name=agent.name,
|
agent_name=agent.name,
|
||||||
@ -144,27 +152,24 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.world.is_night():
|
if self.world.is_night():
|
||||||
# Force sleep at night
|
|
||||||
decision = AIDecision(
|
decision = AIDecision(
|
||||||
action=ActionType.SLEEP,
|
action=ActionType.SLEEP,
|
||||||
reason="Night time: sleeping",
|
reason="Night time: sleeping",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Pass time info so AI can prepare for night
|
|
||||||
decision = get_ai_decision(
|
decision = get_ai_decision(
|
||||||
agent,
|
agent,
|
||||||
self.market,
|
self.market,
|
||||||
step_in_day=self.world.step_in_day,
|
step_in_day=self.world.step_in_day,
|
||||||
day_steps=self.world.config.day_steps,
|
day_steps=self.world.config.day_steps,
|
||||||
current_turn=current_turn,
|
current_turn=current_turn,
|
||||||
|
world=self.world,
|
||||||
)
|
)
|
||||||
|
|
||||||
decisions.append((agent, decision))
|
decisions.append((agent, decision))
|
||||||
|
|
||||||
# Log decision
|
|
||||||
self.logger.log_agent_decision(agent.id, decision.to_dict())
|
self.logger.log_agent_decision(agent.id, decision.to_dict())
|
||||||
|
|
||||||
# 2. Calculate movement targets and move agents
|
# Calculate movement
|
||||||
for agent, decision in decisions:
|
for agent, decision in decisions:
|
||||||
action_name = decision.action.value
|
action_name = decision.action.value
|
||||||
agent.set_action(
|
agent.set_action(
|
||||||
@ -173,14 +178,14 @@ class GameEngine:
|
|||||||
world_height=self.world.config.height,
|
world_height=self.world.config.height,
|
||||||
message=decision.reason,
|
message=decision.reason,
|
||||||
target_resource=decision.target_resource.value if decision.target_resource else None,
|
target_resource=decision.target_resource.value if decision.target_resource else None,
|
||||||
|
target_agent=decision.target_agent_id,
|
||||||
)
|
)
|
||||||
agent.update_movement()
|
agent.update_movement()
|
||||||
|
|
||||||
# 3. Execute all actions and update action indicators with results
|
# Execute actions
|
||||||
for agent, decision in decisions:
|
for agent, decision in decisions:
|
||||||
result = self._execute_action(agent, decision)
|
result = self._execute_action(agent, decision, turn_log)
|
||||||
|
|
||||||
# Complete agent action with result - this updates the indicator to show what was done
|
|
||||||
if result:
|
if result:
|
||||||
agent.complete_action(result.success, result.message)
|
agent.complete_action(result.success, result.message)
|
||||||
|
|
||||||
@ -191,7 +196,6 @@ class GameEngine:
|
|||||||
"result": result.to_dict() if result else None,
|
"result": result.to_dict() if result else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Log agent state after action
|
|
||||||
self.logger.log_agent_after(
|
self.logger.log_agent_after(
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
stats=agent.stats.to_dict(),
|
stats=agent.stats.to_dict(),
|
||||||
@ -201,40 +205,35 @@ class GameEngine:
|
|||||||
action_result=result.to_dict() if result else {},
|
action_result=result.to_dict() if result else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Resolve pending market orders (price updates)
|
# Update market prices
|
||||||
self.market.update_prices(current_turn)
|
self.market.update_prices(current_turn)
|
||||||
|
|
||||||
# Log market state after
|
|
||||||
market_orders_after = [o.to_dict() for o in self.market.get_active_orders()]
|
market_orders_after = [o.to_dict() for o in self.market.get_active_orders()]
|
||||||
self.logger.log_market_state(market_orders_before, market_orders_after)
|
self.logger.log_market_state(market_orders_before, market_orders_after)
|
||||||
|
|
||||||
# 5. Apply passive decay to all living agents
|
# Apply passive decay
|
||||||
for agent in self.world.get_living_agents():
|
for agent in self.world.get_living_agents():
|
||||||
agent.apply_passive_decay()
|
agent.apply_passive_decay()
|
||||||
|
|
||||||
# 6. Decay resources in inventories
|
# Decay resources
|
||||||
for agent in self.world.get_living_agents():
|
for agent in self.world.get_living_agents():
|
||||||
expired = agent.decay_inventory(current_turn)
|
expired = agent.decay_inventory(current_turn)
|
||||||
|
|
||||||
# 7. Mark newly dead agents as corpses (don't remove yet for visualization)
|
# Mark dead agents
|
||||||
newly_dead = self._mark_dead_agents(current_turn)
|
newly_dead = self._mark_dead_agents(current_turn)
|
||||||
for dead_agent in newly_dead:
|
for dead_agent in newly_dead:
|
||||||
cause = dead_agent.death_reason
|
cause = dead_agent.death_reason
|
||||||
self.logger.log_death(dead_agent.name, cause)
|
self.logger.log_death(dead_agent.name, cause)
|
||||||
# Cancel their market orders immediately
|
|
||||||
self.market.cancel_seller_orders(dead_agent.id)
|
self.market.cancel_seller_orders(dead_agent.id)
|
||||||
turn_log.deaths = [a.name for a in newly_dead]
|
turn_log.deaths = [a.name for a in newly_dead]
|
||||||
|
|
||||||
# Log statistics
|
|
||||||
self.logger.log_statistics(self.world.get_statistics())
|
self.logger.log_statistics(self.world.get_statistics())
|
||||||
|
|
||||||
# End turn logging
|
|
||||||
self.logger.end_turn()
|
self.logger.end_turn()
|
||||||
|
|
||||||
# 8. Advance time
|
# Advance time
|
||||||
self.world.advance_time()
|
self.world.advance_time()
|
||||||
|
|
||||||
# 9. Check win/lose conditions (count only truly living agents, not corpses)
|
# Check end conditions
|
||||||
if len(self.world.get_living_agents()) == 0:
|
if len(self.world.get_living_agents()) == 0:
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.logger.close()
|
self.logger.close()
|
||||||
@ -243,14 +242,12 @@ class GameEngine:
|
|||||||
return turn_log
|
return turn_log
|
||||||
|
|
||||||
def _mark_dead_agents(self, current_turn: int) -> list[Agent]:
|
def _mark_dead_agents(self, current_turn: int) -> list[Agent]:
|
||||||
"""Mark agents who just died as corpses. Returns list of newly dead agents."""
|
"""Mark agents who just died as corpses."""
|
||||||
newly_dead = []
|
newly_dead = []
|
||||||
for agent in self.world.agents:
|
for agent in self.world.agents:
|
||||||
if not agent.is_alive() and not agent.is_corpse():
|
if not agent.is_alive() and not agent.is_corpse():
|
||||||
# Agent just died this turn
|
|
||||||
cause = agent.stats.get_critical_stat() or "unknown"
|
cause = agent.stats.get_critical_stat() or "unknown"
|
||||||
agent.mark_dead(current_turn, cause)
|
agent.mark_dead(current_turn, cause)
|
||||||
# Clear their action to show death state
|
|
||||||
agent.current_action.action_type = "dead"
|
agent.current_action.action_type = "dead"
|
||||||
agent.current_action.message = f"Died: {cause}"
|
agent.current_action.message = f"Died: {cause}"
|
||||||
newly_dead.append(agent)
|
newly_dead.append(agent)
|
||||||
@ -261,7 +258,6 @@ class GameEngine:
|
|||||||
to_remove = []
|
to_remove = []
|
||||||
for agent in self.world.agents:
|
for agent in self.world.agents:
|
||||||
if agent.is_corpse() and agent.death_turn < current_turn:
|
if agent.is_corpse() and agent.death_turn < current_turn:
|
||||||
# Corpse has been visible for one turn, remove it
|
|
||||||
to_remove.append(agent)
|
to_remove.append(agent)
|
||||||
|
|
||||||
for agent in to_remove:
|
for agent in to_remove:
|
||||||
@ -270,12 +266,12 @@ class GameEngine:
|
|||||||
|
|
||||||
return to_remove
|
return to_remove
|
||||||
|
|
||||||
def _execute_action(self, agent: Agent, decision: AIDecision) -> Optional[ActionResult]:
|
def _execute_action(self, agent: Agent, decision: AIDecision, turn_log: TurnLog) -> Optional[ActionResult]:
|
||||||
"""Execute an action for an agent."""
|
"""Execute an action for an agent."""
|
||||||
action = decision.action
|
action = decision.action
|
||||||
config = ACTION_CONFIG[action]
|
config = ACTION_CONFIG[action]
|
||||||
|
|
||||||
# Handle different action types
|
# Basic actions
|
||||||
if action == ActionType.SLEEP:
|
if action == ActionType.SLEEP:
|
||||||
agent.restore_energy(config.energy_cost)
|
agent.restore_energy(config.energy_cost)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -309,8 +305,6 @@ class GameEngine:
|
|||||||
agent.remove_from_inventory(ResourceType.WOOD, 1)
|
agent.remove_from_inventory(ResourceType.WOOD, 1)
|
||||||
if not agent.spend_energy(abs(config.energy_cost)):
|
if not agent.spend_energy(abs(config.energy_cost)):
|
||||||
return ActionResult(action_type=action, success=False, message="Not enough energy")
|
return ActionResult(action_type=action, success=False, message="Not enough energy")
|
||||||
# Fire heat from config
|
|
||||||
from backend.domain.resources import get_fire_heat
|
|
||||||
fire_heat = get_fire_heat()
|
fire_heat = get_fire_heat()
|
||||||
agent.apply_heat(fire_heat)
|
agent.apply_heat(fire_heat)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -322,25 +316,56 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
return ActionResult(action_type=action, success=False, message="No wood for fire")
|
return ActionResult(action_type=action, success=False, message="No wood for fire")
|
||||||
|
|
||||||
|
elif action == ActionType.BURN_FUEL:
|
||||||
|
if agent.has_resource(ResourceType.FUEL):
|
||||||
|
agent.remove_from_inventory(ResourceType.FUEL, 1)
|
||||||
|
if not agent.spend_energy(abs(config.energy_cost)):
|
||||||
|
return ActionResult(action_type=action, success=False, message="Not enough energy")
|
||||||
|
fuel_heat = get_fuel_heat()
|
||||||
|
agent.apply_heat(fuel_heat)
|
||||||
|
# Fuel also provides energy
|
||||||
|
from backend.config import get_config
|
||||||
|
fuel_energy = get_config().resources.fuel_energy
|
||||||
|
agent.restore_energy(fuel_energy)
|
||||||
|
return ActionResult(
|
||||||
|
action_type=action,
|
||||||
|
success=True,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
heat_gained=fuel_heat,
|
||||||
|
message=f"Burned fuel (+{fuel_heat} heat, +{fuel_energy} energy)",
|
||||||
|
)
|
||||||
|
return ActionResult(action_type=action, success=False, message="No fuel to burn")
|
||||||
|
|
||||||
elif action == ActionType.TRADE:
|
elif action == ActionType.TRADE:
|
||||||
return self._execute_trade(agent, decision)
|
return self._execute_trade(agent, decision)
|
||||||
|
|
||||||
|
# Religious actions
|
||||||
|
elif action == ActionType.PRAY:
|
||||||
|
return self._execute_pray(agent, config, turn_log)
|
||||||
|
|
||||||
|
elif action == ActionType.PREACH:
|
||||||
|
return self._execute_preach(agent, config, turn_log)
|
||||||
|
|
||||||
|
# Diplomatic actions
|
||||||
|
elif action == ActionType.NEGOTIATE:
|
||||||
|
return self._execute_negotiate(agent, decision, config, turn_log)
|
||||||
|
|
||||||
|
elif action == ActionType.DECLARE_WAR:
|
||||||
|
return self._execute_declare_war(agent, decision, config, turn_log)
|
||||||
|
|
||||||
|
elif action == ActionType.MAKE_PEACE:
|
||||||
|
return self._execute_make_peace(agent, decision, config, turn_log)
|
||||||
|
|
||||||
|
# Production actions
|
||||||
elif action in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
elif action in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||||
ActionType.GET_WATER, ActionType.WEAVE]:
|
ActionType.GET_WATER, ActionType.WEAVE, ActionType.DRILL_OIL,
|
||||||
|
ActionType.REFINE]:
|
||||||
return self._execute_work(agent, action, config)
|
return self._execute_work(agent, action, config)
|
||||||
|
|
||||||
return ActionResult(action_type=action, success=False, message="Unknown action")
|
return ActionResult(action_type=action, success=False, message="Unknown action")
|
||||||
|
|
||||||
def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult:
|
def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult:
|
||||||
"""Execute a work action (hunting, gathering, etc.).
|
"""Execute a work action (hunting, gathering, drilling, etc.)."""
|
||||||
|
|
||||||
Skills now affect outcomes:
|
|
||||||
- Hunting skill affects hunt success rate
|
|
||||||
- Gathering skill affects gather output
|
|
||||||
- Woodcutting skill affects wood output
|
|
||||||
- Skills improve with use
|
|
||||||
"""
|
|
||||||
# Check energy
|
|
||||||
energy_cost = abs(config.energy_cost)
|
energy_cost = abs(config.energy_cost)
|
||||||
if not agent.spend_energy(energy_cost):
|
if not agent.spend_energy(energy_cost):
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -349,10 +374,9 @@ class GameEngine:
|
|||||||
message="Not enough energy",
|
message="Not enough energy",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check required materials
|
|
||||||
if config.requires_resource:
|
if config.requires_resource:
|
||||||
if not agent.has_resource(config.requires_resource, config.requires_quantity):
|
if not agent.has_resource(config.requires_resource, config.requires_quantity):
|
||||||
agent.restore_energy(energy_cost) # Refund energy
|
agent.restore_energy(energy_cost)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=action,
|
action_type=action,
|
||||||
success=False,
|
success=False,
|
||||||
@ -360,19 +384,22 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
|
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
|
||||||
|
|
||||||
# Get relevant skill for this action
|
# Get skill modifier
|
||||||
skill_name = self._get_skill_for_action(action)
|
skill_name = self._get_skill_for_action(action)
|
||||||
skill_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0
|
skill_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0
|
||||||
skill_modifier = get_action_skill_modifier(skill_value)
|
skill_modifier = get_action_skill_modifier(skill_value)
|
||||||
|
|
||||||
# Check success chance (modified by skill)
|
# Get religion bonus
|
||||||
# Higher skill = higher effective success chance
|
religion_bonus = get_religion_action_bonus(agent.religion.religion, action.value)
|
||||||
effective_success_chance = min(0.98, config.success_chance * skill_modifier)
|
|
||||||
|
# Combined modifier
|
||||||
|
total_modifier = skill_modifier * religion_bonus
|
||||||
|
|
||||||
|
effective_success_chance = min(0.98, config.success_chance * total_modifier)
|
||||||
if random.random() > effective_success_chance:
|
if random.random() > effective_success_chance:
|
||||||
# Record action attempt (skill still improves on failure, just less)
|
|
||||||
agent.record_action(action.value)
|
agent.record_action(action.value)
|
||||||
if skill_name:
|
if skill_name:
|
||||||
agent.skills.improve(skill_name, 0.005) # Small improvement on failure
|
agent.skills.improve(skill_name, 0.005)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=action,
|
action_type=action,
|
||||||
success=False,
|
success=False,
|
||||||
@ -380,13 +407,11 @@ class GameEngine:
|
|||||||
message="Action failed",
|
message="Action failed",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate output (modified by skill for quantity)
|
|
||||||
resources_gained = []
|
resources_gained = []
|
||||||
|
|
||||||
if config.output_resource:
|
if config.output_resource:
|
||||||
# Skill affects output quantity
|
|
||||||
base_quantity = random.randint(config.min_output, config.max_output)
|
base_quantity = random.randint(config.min_output, config.max_output)
|
||||||
quantity = max(config.min_output, int(base_quantity * skill_modifier))
|
quantity = max(config.min_output, int(base_quantity * total_modifier))
|
||||||
|
|
||||||
if quantity > 0:
|
if quantity > 0:
|
||||||
resource = Resource(
|
resource = Resource(
|
||||||
@ -402,10 +427,9 @@ class GameEngine:
|
|||||||
created_turn=self.world.current_turn,
|
created_turn=self.world.current_turn,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Secondary output (e.g., hide from hunting) - also affected by skill
|
|
||||||
if config.secondary_output:
|
if config.secondary_output:
|
||||||
base_quantity = random.randint(config.secondary_min, config.secondary_max)
|
base_quantity = random.randint(config.secondary_min, config.secondary_max)
|
||||||
quantity = max(0, int(base_quantity * skill_modifier))
|
quantity = max(0, int(base_quantity * total_modifier))
|
||||||
if quantity > 0:
|
if quantity > 0:
|
||||||
resource = Resource(
|
resource = Resource(
|
||||||
type=config.secondary_output,
|
type=config.secondary_output,
|
||||||
@ -420,12 +444,10 @@ class GameEngine:
|
|||||||
created_turn=self.world.current_turn,
|
created_turn=self.world.current_turn,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Record action and improve skill
|
|
||||||
agent.record_action(action.value)
|
agent.record_action(action.value)
|
||||||
if skill_name:
|
if skill_name:
|
||||||
agent.skills.improve(skill_name, 0.015) # Skill improves with successful use
|
agent.skills.improve(skill_name, 0.015)
|
||||||
|
|
||||||
# Build success message with details
|
|
||||||
gained_str = ", ".join(f"+{r.quantity} {r.type.value}" for r in resources_gained)
|
gained_str = ", ".join(f"+{r.quantity} {r.type.value}" for r in resources_gained)
|
||||||
message = f"{action.value}: {gained_str}" if gained_str else f"{action.value} (nothing gained)"
|
message = f"{action.value}: {gained_str}" if gained_str else f"{action.value} (nothing gained)"
|
||||||
|
|
||||||
@ -438,32 +460,239 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _get_skill_for_action(self, action: ActionType) -> Optional[str]:
|
def _get_skill_for_action(self, action: ActionType) -> Optional[str]:
|
||||||
"""Get the skill name that affects a given action."""
|
"""Get the skill name for an action."""
|
||||||
skill_map = {
|
skill_map = {
|
||||||
ActionType.HUNT: "hunting",
|
ActionType.HUNT: "hunting",
|
||||||
ActionType.GATHER: "gathering",
|
ActionType.GATHER: "gathering",
|
||||||
ActionType.CHOP_WOOD: "woodcutting",
|
ActionType.CHOP_WOOD: "woodcutting",
|
||||||
ActionType.WEAVE: "crafting",
|
ActionType.WEAVE: "crafting",
|
||||||
|
ActionType.DRILL_OIL: "gathering", # Use gathering skill for now
|
||||||
|
ActionType.REFINE: "crafting",
|
||||||
}
|
}
|
||||||
return skill_map.get(action)
|
return skill_map.get(action)
|
||||||
|
|
||||||
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
def _execute_pray(self, agent: Agent, config, turn_log: TurnLog) -> ActionResult:
|
||||||
"""Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades.
|
"""Execute prayer action."""
|
||||||
|
if not agent.spend_energy(abs(config.energy_cost)):
|
||||||
|
return ActionResult(action_type=ActionType.PRAY, success=False, message="Not enough energy")
|
||||||
|
|
||||||
Trading skill improves with successful trades and affects prices slightly.
|
faith_gain = config.faith_gain
|
||||||
"""
|
agent.gain_faith(faith_gain)
|
||||||
|
agent.religion.record_prayer(self.world.current_turn)
|
||||||
|
agent.record_action("pray")
|
||||||
|
|
||||||
|
turn_log.religious_events.append({
|
||||||
|
"type": "prayer",
|
||||||
|
"agent_id": agent.id,
|
||||||
|
"agent_name": agent.name,
|
||||||
|
"religion": agent.religion.religion.value,
|
||||||
|
"faith_gained": faith_gain,
|
||||||
|
"new_faith": agent.stats.faith,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ActionResult(
|
||||||
|
action_type=ActionType.PRAY,
|
||||||
|
success=True,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
faith_gained=faith_gain,
|
||||||
|
message=f"Prayed to {agent.religion.religion.value} (+{faith_gain} faith)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_preach(self, agent: Agent, config, turn_log: TurnLog) -> ActionResult:
|
||||||
|
"""Execute preaching action to spread religion."""
|
||||||
|
if not agent.spend_energy(abs(config.energy_cost)):
|
||||||
|
return ActionResult(action_type=ActionType.PREACH, success=False, message="Not enough energy")
|
||||||
|
|
||||||
|
# Find nearby agents to potentially convert
|
||||||
|
nearby = self.world.get_nearby_agents(agent, radius=4.0)
|
||||||
|
conversions = 0
|
||||||
|
|
||||||
|
for target in nearby:
|
||||||
|
if target.religion.religion == agent.religion.religion:
|
||||||
|
# Same religion - boost their faith
|
||||||
|
target.gain_faith(config.faith_spread // 2)
|
||||||
|
else:
|
||||||
|
# Different religion - try to convert
|
||||||
|
if random.random() < config.success_chance:
|
||||||
|
if target.religion.convert_to(agent.religion.religion, 40):
|
||||||
|
conversions += 1
|
||||||
|
agent.religion.record_conversion()
|
||||||
|
self.world.total_conversions += 1
|
||||||
|
|
||||||
|
turn_log.religious_events.append({
|
||||||
|
"type": "conversion",
|
||||||
|
"preacher_id": agent.id,
|
||||||
|
"convert_id": target.id,
|
||||||
|
"convert_name": target.name,
|
||||||
|
"new_religion": agent.religion.religion.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
agent.religion.record_sermon()
|
||||||
|
agent.record_action("preach")
|
||||||
|
|
||||||
|
# Preaching also boosts own faith
|
||||||
|
agent.gain_faith(config.faith_spread // 2)
|
||||||
|
|
||||||
|
if conversions > 0:
|
||||||
|
message = f"Converted {conversions} to {agent.religion.religion.value}!"
|
||||||
|
else:
|
||||||
|
message = f"Preached the word of {agent.religion.religion.value}"
|
||||||
|
|
||||||
|
return ActionResult(
|
||||||
|
action_type=ActionType.PREACH,
|
||||||
|
success=True,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
faith_gained=config.faith_spread // 2,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_negotiate(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||||
|
"""Execute diplomatic negotiation."""
|
||||||
|
if not agent.spend_energy(abs(config.energy_cost)):
|
||||||
|
return ActionResult(action_type=ActionType.NEGOTIATE, success=False, message="Not enough energy")
|
||||||
|
|
||||||
|
target_faction = decision.target_faction
|
||||||
|
if not target_faction:
|
||||||
|
return ActionResult(action_type=ActionType.NEGOTIATE, success=False, message="No target faction")
|
||||||
|
|
||||||
|
faction_relations = get_faction_relations()
|
||||||
|
my_faction = agent.diplomacy.faction
|
||||||
|
|
||||||
|
# Attempt negotiation
|
||||||
|
if random.random() < config.success_chance * agent.diplomacy.diplomacy_skill:
|
||||||
|
# Successful negotiation improves relations
|
||||||
|
from backend.config import get_config
|
||||||
|
boost = get_config().diplomacy.trade_relation_boost * 2
|
||||||
|
new_relation = faction_relations.modify_relation(my_faction, target_faction, int(boost))
|
||||||
|
|
||||||
|
agent.diplomacy.negotiations_conducted += 1
|
||||||
|
agent.record_action("negotiate")
|
||||||
|
|
||||||
|
turn_log.diplomatic_events.append({
|
||||||
|
"type": "negotiation",
|
||||||
|
"agent_id": agent.id,
|
||||||
|
"agent_faction": my_faction.value,
|
||||||
|
"target_faction": target_faction.value,
|
||||||
|
"success": True,
|
||||||
|
"new_relation": new_relation,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ActionResult(
|
||||||
|
action_type=ActionType.NEGOTIATE,
|
||||||
|
success=True,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
relation_change=int(boost),
|
||||||
|
target_faction=target_faction.value,
|
||||||
|
diplomatic_effect="improved",
|
||||||
|
message=f"Improved relations with {target_faction.value} (+{int(boost)})",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
agent.record_action("negotiate")
|
||||||
|
return ActionResult(
|
||||||
|
action_type=ActionType.NEGOTIATE,
|
||||||
|
success=False,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
target_faction=target_faction.value,
|
||||||
|
message=f"Negotiations with {target_faction.value} failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_declare_war(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||||
|
"""Execute war declaration."""
|
||||||
|
if not agent.spend_energy(abs(config.energy_cost)):
|
||||||
|
return ActionResult(action_type=ActionType.DECLARE_WAR, success=False, message="Not enough energy")
|
||||||
|
|
||||||
|
target_faction = decision.target_faction
|
||||||
|
if not target_faction:
|
||||||
|
return ActionResult(action_type=ActionType.DECLARE_WAR, success=False, message="No target faction")
|
||||||
|
|
||||||
|
faction_relations = get_faction_relations()
|
||||||
|
my_faction = agent.diplomacy.faction
|
||||||
|
|
||||||
|
success = faction_relations.declare_war(my_faction, target_faction, self.world.current_turn)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.world.total_wars += 1
|
||||||
|
agent.diplomacy.wars_declared += 1
|
||||||
|
agent.record_action("declare_war")
|
||||||
|
|
||||||
|
turn_log.diplomatic_events.append({
|
||||||
|
"type": "war_declaration",
|
||||||
|
"agent_id": agent.id,
|
||||||
|
"aggressor_faction": my_faction.value,
|
||||||
|
"defender_faction": target_faction.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ActionResult(
|
||||||
|
action_type=ActionType.DECLARE_WAR,
|
||||||
|
success=True,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
target_faction=target_faction.value,
|
||||||
|
diplomatic_effect="war",
|
||||||
|
message=f"Declared WAR on {target_faction.value}!",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ActionResult(
|
||||||
|
action_type=ActionType.DECLARE_WAR,
|
||||||
|
success=False,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
message=f"Already at war with {target_faction.value}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_make_peace(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||||
|
"""Execute peace treaty."""
|
||||||
|
if not agent.spend_energy(abs(config.energy_cost)):
|
||||||
|
return ActionResult(action_type=ActionType.MAKE_PEACE, success=False, message="Not enough energy")
|
||||||
|
|
||||||
|
target_faction = decision.target_faction
|
||||||
|
if not target_faction:
|
||||||
|
return ActionResult(action_type=ActionType.MAKE_PEACE, success=False, message="No target faction")
|
||||||
|
|
||||||
|
faction_relations = get_faction_relations()
|
||||||
|
my_faction = agent.diplomacy.faction
|
||||||
|
|
||||||
|
# Peace is harder to achieve
|
||||||
|
if random.random() < config.success_chance * agent.diplomacy.diplomacy_skill:
|
||||||
|
success = faction_relations.make_peace(my_faction, target_faction, self.world.current_turn)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.world.total_peace_treaties += 1
|
||||||
|
agent.diplomacy.peace_treaties_made += 1
|
||||||
|
agent.record_action("make_peace")
|
||||||
|
|
||||||
|
turn_log.diplomatic_events.append({
|
||||||
|
"type": "peace_treaty",
|
||||||
|
"agent_id": agent.id,
|
||||||
|
"faction1": my_faction.value,
|
||||||
|
"faction2": target_faction.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ActionResult(
|
||||||
|
action_type=ActionType.MAKE_PEACE,
|
||||||
|
success=True,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
target_faction=target_faction.value,
|
||||||
|
diplomatic_effect="peace",
|
||||||
|
message=f"Peace treaty signed with {target_faction.value}!",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ActionResult(
|
||||||
|
action_type=ActionType.MAKE_PEACE,
|
||||||
|
success=False,
|
||||||
|
energy_spent=abs(config.energy_cost),
|
||||||
|
message=f"Peace negotiations with {target_faction.value} failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
||||||
|
"""Execute a trade action."""
|
||||||
config = ACTION_CONFIG[ActionType.TRADE]
|
config = ACTION_CONFIG[ActionType.TRADE]
|
||||||
|
|
||||||
# Handle price adjustments (no energy cost)
|
|
||||||
if decision.adjust_order_id and decision.new_price is not None:
|
if decision.adjust_order_id and decision.new_price is not None:
|
||||||
return self._execute_price_adjustment(agent, decision)
|
return self._execute_price_adjustment(agent, decision)
|
||||||
|
|
||||||
# Handle multi-item trades
|
|
||||||
if decision.trade_items:
|
if decision.trade_items:
|
||||||
return self._execute_multi_buy(agent, decision)
|
return self._execute_multi_buy(agent, decision)
|
||||||
|
|
||||||
if decision.order_id:
|
if decision.order_id:
|
||||||
# Buying single item from market
|
|
||||||
result = self.market.execute_buy(
|
result = self.market.execute_buy(
|
||||||
buyer_id=agent.id,
|
buyer_id=agent.id,
|
||||||
order_id=decision.order_id,
|
order_id=decision.order_id,
|
||||||
@ -472,10 +701,8 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
# Log the trade
|
|
||||||
self.logger.log_trade(result.to_dict())
|
self.logger.log_trade(result.to_dict())
|
||||||
|
|
||||||
# Record sale for price history tracking
|
|
||||||
self.market._record_sale(
|
self.market._record_sale(
|
||||||
result.resource_type,
|
result.resource_type,
|
||||||
result.total_paid // result.quantity,
|
result.total_paid // result.quantity,
|
||||||
@ -483,10 +710,8 @@ class GameEngine:
|
|||||||
self.world.current_turn,
|
self.world.current_turn,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Deduct money from buyer
|
|
||||||
agent.money -= result.total_paid
|
agent.money -= result.total_paid
|
||||||
|
|
||||||
# Add resources to buyer
|
|
||||||
resource = Resource(
|
resource = Resource(
|
||||||
type=result.resource_type,
|
type=result.resource_type,
|
||||||
quantity=result.quantity,
|
quantity=result.quantity,
|
||||||
@ -494,18 +719,25 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
agent.add_to_inventory(resource)
|
agent.add_to_inventory(resource)
|
||||||
|
|
||||||
# Add money to seller and record their trade
|
|
||||||
seller = self.world.get_agent(result.seller_id)
|
seller = self.world.get_agent(result.seller_id)
|
||||||
if seller:
|
if seller:
|
||||||
seller.money += result.total_paid
|
seller.money += result.total_paid
|
||||||
seller.record_trade(result.total_paid)
|
seller.record_trade(result.total_paid)
|
||||||
seller.skills.improve("trading", 0.02) # Seller skill improves
|
seller.skills.improve("trading", 0.02)
|
||||||
|
|
||||||
|
# Improve faction relations from trade
|
||||||
|
faction_relations = get_faction_relations()
|
||||||
|
from backend.config import get_config
|
||||||
|
boost = get_config().diplomacy.trade_relation_boost
|
||||||
|
faction_relations.modify_relation(
|
||||||
|
agent.diplomacy.faction,
|
||||||
|
seller.diplomacy.faction,
|
||||||
|
boost
|
||||||
|
)
|
||||||
|
|
||||||
agent.spend_energy(abs(config.energy_cost))
|
agent.spend_energy(abs(config.energy_cost))
|
||||||
|
|
||||||
# Record buyer's trade and improve skill
|
|
||||||
agent.record_action("trade")
|
agent.record_action("trade")
|
||||||
agent.skills.improve("trading", 0.01) # Buyer skill improves less
|
agent.skills.improve("trading", 0.01)
|
||||||
|
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=ActionType.TRADE,
|
action_type=ActionType.TRADE,
|
||||||
@ -522,7 +754,6 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif decision.target_resource and decision.quantity > 0:
|
elif decision.target_resource and decision.quantity > 0:
|
||||||
# Selling to market (listing)
|
|
||||||
if agent.has_resource(decision.target_resource, decision.quantity):
|
if agent.has_resource(decision.target_resource, decision.quantity):
|
||||||
agent.remove_from_inventory(decision.target_resource, decision.quantity)
|
agent.remove_from_inventory(decision.target_resource, decision.quantity)
|
||||||
|
|
||||||
@ -535,7 +766,7 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
agent.spend_energy(abs(config.energy_cost))
|
agent.spend_energy(abs(config.energy_cost))
|
||||||
agent.record_action("trade") # Track listing action
|
agent.record_action("trade")
|
||||||
|
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=ActionType.TRADE,
|
action_type=ActionType.TRADE,
|
||||||
@ -557,7 +788,7 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _execute_price_adjustment(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
def _execute_price_adjustment(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
||||||
"""Execute a price adjustment on an existing order (no energy cost)."""
|
"""Execute a price adjustment."""
|
||||||
success = self.market.adjust_order_price(
|
success = self.market.adjust_order_price(
|
||||||
order_id=decision.adjust_order_id,
|
order_id=decision.adjust_order_id,
|
||||||
seller_id=agent.id,
|
seller_id=agent.id,
|
||||||
@ -569,8 +800,8 @@ class GameEngine:
|
|||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=ActionType.TRADE,
|
action_type=ActionType.TRADE,
|
||||||
success=True,
|
success=True,
|
||||||
energy_spent=0, # Price adjustments are free
|
energy_spent=0,
|
||||||
message=f"Adjusted {decision.target_resource.value} price to {decision.new_price}c",
|
message=f"Adjusted price to {decision.new_price}c",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -583,17 +814,13 @@ class GameEngine:
|
|||||||
"""Execute a multi-item buy trade."""
|
"""Execute a multi-item buy trade."""
|
||||||
config = ACTION_CONFIG[ActionType.TRADE]
|
config = ACTION_CONFIG[ActionType.TRADE]
|
||||||
|
|
||||||
# Build list of purchases
|
|
||||||
purchases = [(item.order_id, item.quantity) for item in decision.trade_items]
|
purchases = [(item.order_id, item.quantity) for item in decision.trade_items]
|
||||||
|
|
||||||
# Execute all purchases
|
|
||||||
results = self.market.execute_multi_buy(
|
results = self.market.execute_multi_buy(
|
||||||
buyer_id=agent.id,
|
buyer_id=agent.id,
|
||||||
purchases=purchases,
|
purchases=purchases,
|
||||||
buyer_money=agent.money,
|
buyer_money=agent.money,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process results
|
|
||||||
total_paid = 0
|
total_paid = 0
|
||||||
resources_gained = []
|
resources_gained = []
|
||||||
items_bought = []
|
items_bought = []
|
||||||
@ -604,7 +831,6 @@ class GameEngine:
|
|||||||
agent.money -= result.total_paid
|
agent.money -= result.total_paid
|
||||||
total_paid += result.total_paid
|
total_paid += result.total_paid
|
||||||
|
|
||||||
# Record sale for price history
|
|
||||||
self.market._record_sale(
|
self.market._record_sale(
|
||||||
result.resource_type,
|
result.resource_type,
|
||||||
result.total_paid // result.quantity,
|
result.total_paid // result.quantity,
|
||||||
@ -621,7 +847,6 @@ class GameEngine:
|
|||||||
resources_gained.append(resource)
|
resources_gained.append(resource)
|
||||||
items_bought.append(f"{result.quantity} {result.resource_type.value}")
|
items_bought.append(f"{result.quantity} {result.resource_type.value}")
|
||||||
|
|
||||||
# Add money to seller
|
|
||||||
seller = self.world.get_agent(result.seller_id)
|
seller = self.world.get_agent(result.seller_id)
|
||||||
if seller:
|
if seller:
|
||||||
seller.money += result.total_paid
|
seller.money += result.total_paid
|
||||||
@ -687,7 +912,6 @@ class GameEngine:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Global engine instance
|
|
||||||
def get_engine() -> GameEngine:
|
def get_engine() -> GameEngine:
|
||||||
"""Get the global game engine instance."""
|
"""Get the global game engine instance."""
|
||||||
return GameEngine()
|
return GameEngine()
|
||||||
|
|||||||
@ -3,6 +3,9 @@
|
|||||||
The world spawns diverse agents with varied personality traits,
|
The world spawns diverse agents with varied personality traits,
|
||||||
skills, and starting conditions to create emergent professions
|
skills, and starting conditions to create emergent professions
|
||||||
and class inequality.
|
and class inequality.
|
||||||
|
|
||||||
|
NEW: World now supports religion and faction systems for realistic
|
||||||
|
social dynamics including religious diversity and geopolitical factions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
@ -15,6 +18,13 @@ from backend.domain.personality import (
|
|||||||
PersonalityTraits, Skills,
|
PersonalityTraits, Skills,
|
||||||
generate_random_personality, generate_random_skills
|
generate_random_personality, generate_random_skills
|
||||||
)
|
)
|
||||||
|
from backend.domain.religion import (
|
||||||
|
ReligiousBeliefs, ReligionType, generate_random_religion
|
||||||
|
)
|
||||||
|
from backend.domain.diplomacy import (
|
||||||
|
AgentDiplomacy, FactionType, FactionRelations,
|
||||||
|
generate_random_faction, reset_faction_relations, get_faction_relations
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TimeOfDay(Enum):
|
class TimeOfDay(Enum):
|
||||||
@ -31,16 +41,14 @@ def _get_world_config_from_file():
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorldConfig:
|
class WorldConfig:
|
||||||
"""Configuration for the world.
|
"""Configuration for the world."""
|
||||||
|
|
||||||
Default values are loaded from config.json via create_world_config().
|
|
||||||
These hardcoded defaults are only fallbacks.
|
|
||||||
"""
|
|
||||||
width: int = 25
|
width: int = 25
|
||||||
height: int = 25
|
height: int = 25
|
||||||
initial_agents: int = 25
|
initial_agents: int = 25
|
||||||
day_steps: int = 10
|
day_steps: int = 10
|
||||||
night_steps: int = 1
|
night_steps: int = 1
|
||||||
|
oil_fields_count: int = 3 # NEW
|
||||||
|
temple_count: int = 2 # NEW
|
||||||
|
|
||||||
|
|
||||||
def create_world_config() -> WorldConfig:
|
def create_world_config() -> WorldConfig:
|
||||||
@ -52,9 +60,21 @@ def create_world_config() -> WorldConfig:
|
|||||||
initial_agents=cfg.initial_agents,
|
initial_agents=cfg.initial_agents,
|
||||||
day_steps=cfg.day_steps,
|
day_steps=cfg.day_steps,
|
||||||
night_steps=cfg.night_steps,
|
night_steps=cfg.night_steps,
|
||||||
|
oil_fields_count=getattr(cfg, 'oil_fields_count', 3),
|
||||||
|
temple_count=getattr(cfg, 'temple_count', 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorldLocation:
|
||||||
|
"""A special location in the world."""
|
||||||
|
name: str
|
||||||
|
position: Position
|
||||||
|
location_type: str # "oil_field", "temple", "market", etc.
|
||||||
|
faction: Optional[FactionType] = None
|
||||||
|
religion: Optional[ReligionType] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class World:
|
class World:
|
||||||
"""Container for all entities in the simulation."""
|
"""Container for all entities in the simulation."""
|
||||||
@ -65,9 +85,46 @@ class World:
|
|||||||
step_in_day: int = 0
|
step_in_day: int = 0
|
||||||
time_of_day: TimeOfDay = TimeOfDay.DAY
|
time_of_day: TimeOfDay = TimeOfDay.DAY
|
||||||
|
|
||||||
|
# Special locations
|
||||||
|
oil_fields: list[WorldLocation] = field(default_factory=list)
|
||||||
|
temples: list[WorldLocation] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Faction relations
|
||||||
|
faction_relations: FactionRelations = field(default_factory=FactionRelations)
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
total_agents_spawned: int = 0
|
total_agents_spawned: int = 0
|
||||||
total_agents_died: int = 0
|
total_agents_died: int = 0
|
||||||
|
total_wars: int = 0
|
||||||
|
total_peace_treaties: int = 0
|
||||||
|
total_conversions: int = 0
|
||||||
|
|
||||||
|
def _generate_locations(self) -> None:
|
||||||
|
"""Generate special locations in the world."""
|
||||||
|
# Generate oil fields (right side of map - "resource-rich" area)
|
||||||
|
self.oil_fields = []
|
||||||
|
for i in range(self.config.oil_fields_count):
|
||||||
|
x = self.config.width * random.uniform(0.75, 0.95)
|
||||||
|
y = self.config.height * (i + 1) / (self.config.oil_fields_count + 1)
|
||||||
|
self.oil_fields.append(WorldLocation(
|
||||||
|
name=f"Oil Field {i + 1}",
|
||||||
|
position=Position(x, y),
|
||||||
|
location_type="oil_field",
|
||||||
|
faction=random.choice([FactionType.MOUNTAINEER, FactionType.NORTHLANDS]),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Generate temples (scattered across map)
|
||||||
|
self.temples = []
|
||||||
|
religions = [r for r in ReligionType if r != ReligionType.ATHEIST]
|
||||||
|
for i in range(self.config.temple_count):
|
||||||
|
x = self.config.width * random.uniform(0.3, 0.7)
|
||||||
|
y = self.config.height * (i + 1) / (self.config.temple_count + 1)
|
||||||
|
self.temples.append(WorldLocation(
|
||||||
|
name=f"Temple of {religions[i % len(religions)].value.title()}",
|
||||||
|
position=Position(x, y),
|
||||||
|
location_type="temple",
|
||||||
|
religion=religions[i % len(religions)],
|
||||||
|
))
|
||||||
|
|
||||||
def spawn_agent(
|
def spawn_agent(
|
||||||
self,
|
self,
|
||||||
@ -76,6 +133,8 @@ class World:
|
|||||||
position: Optional[Position] = None,
|
position: Optional[Position] = None,
|
||||||
archetype: Optional[str] = None,
|
archetype: Optional[str] = None,
|
||||||
starting_money: Optional[int] = None,
|
starting_money: Optional[int] = None,
|
||||||
|
religion: Optional[ReligiousBeliefs] = None,
|
||||||
|
faction: Optional[AgentDiplomacy] = None,
|
||||||
) -> Agent:
|
) -> Agent:
|
||||||
"""Spawn a new agent in the world with unique personality.
|
"""Spawn a new agent in the world with unique personality.
|
||||||
|
|
||||||
@ -85,6 +144,8 @@ class World:
|
|||||||
position: Starting position (random if None)
|
position: Starting position (random if None)
|
||||||
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
|
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
|
||||||
starting_money: Starting money (random with inequality if None)
|
starting_money: Starting money (random with inequality if None)
|
||||||
|
religion: Religious beliefs (random if None)
|
||||||
|
faction: Faction membership (random if None)
|
||||||
"""
|
"""
|
||||||
if position is None:
|
if position is None:
|
||||||
position = Position(
|
position = Position(
|
||||||
@ -96,25 +157,38 @@ class World:
|
|||||||
personality = generate_random_personality(archetype)
|
personality = generate_random_personality(archetype)
|
||||||
skills = generate_random_skills(personality)
|
skills = generate_random_skills(personality)
|
||||||
|
|
||||||
|
# Generate religion if not provided
|
||||||
|
if religion is None:
|
||||||
|
religion = generate_random_religion(archetype)
|
||||||
|
|
||||||
|
# Generate faction if not provided
|
||||||
|
if faction is None:
|
||||||
|
faction = generate_random_faction(archetype)
|
||||||
|
|
||||||
# Variable starting money for class inequality
|
# Variable starting money for class inequality
|
||||||
# Some agents start with more, some with less
|
|
||||||
if starting_money is None:
|
if starting_money is None:
|
||||||
from backend.config import get_config
|
from backend.config import get_config
|
||||||
base_money = get_config().world.starting_money
|
base_money = get_config().world.starting_money
|
||||||
# Random multiplier: 0.3x to 2.0x base money
|
|
||||||
# This creates natural class inequality
|
|
||||||
money_multiplier = random.uniform(0.3, 2.0)
|
money_multiplier = random.uniform(0.3, 2.0)
|
||||||
|
|
||||||
# Traders start with more money (their capital)
|
# Traders start with more money (their capital)
|
||||||
if personality.trade_preference > 1.3:
|
if personality.trade_preference > 1.3:
|
||||||
money_multiplier *= 1.5
|
money_multiplier *= 1.5
|
||||||
|
|
||||||
|
# Oil-controlling factions have wealth bonus
|
||||||
|
if faction.faction == FactionType.MOUNTAINEER:
|
||||||
|
money_multiplier *= 1.3
|
||||||
|
|
||||||
starting_money = int(base_money * money_multiplier)
|
starting_money = int(base_money * money_multiplier)
|
||||||
|
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name=name or f"Villager_{self.total_agents_spawned + 1}",
|
name=name or f"Villager_{self.total_agents_spawned + 1}",
|
||||||
profession=Profession.VILLAGER, # Will be updated based on personality
|
profession=Profession.VILLAGER,
|
||||||
position=position,
|
position=position,
|
||||||
personality=personality,
|
personality=personality,
|
||||||
skills=skills,
|
skills=skills,
|
||||||
|
religion=religion,
|
||||||
|
diplomacy=faction,
|
||||||
money=starting_money,
|
money=starting_money,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -129,12 +203,35 @@ class World:
|
|||||||
return agent
|
return agent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_agents_by_faction(self, faction: FactionType) -> list[Agent]:
|
||||||
|
"""Get all living agents in a faction."""
|
||||||
|
return [
|
||||||
|
a for a in self.agents
|
||||||
|
if a.is_alive() and not a.is_corpse() and a.diplomacy.faction == faction
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_agents_by_religion(self, religion: ReligionType) -> list[Agent]:
|
||||||
|
"""Get all living agents of a religion."""
|
||||||
|
return [
|
||||||
|
a for a in self.agents
|
||||||
|
if a.is_alive() and not a.is_corpse() and a.religion.religion == religion
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_nearby_agents(self, agent: Agent, radius: float = 3.0) -> list[Agent]:
|
||||||
|
"""Get living agents near a given agent."""
|
||||||
|
nearby = []
|
||||||
|
for other in self.agents:
|
||||||
|
if other.id == agent.id:
|
||||||
|
continue
|
||||||
|
if not other.is_alive() or other.is_corpse():
|
||||||
|
continue
|
||||||
|
if agent.position.distance_to(other.position) <= radius:
|
||||||
|
nearby.append(other)
|
||||||
|
return nearby
|
||||||
|
|
||||||
def remove_dead_agents(self) -> list[Agent]:
|
def remove_dead_agents(self) -> list[Agent]:
|
||||||
"""Remove all dead agents from the world. Returns list of removed agents.
|
"""Remove all dead agents from the world."""
|
||||||
Note: This is now handled by the engine's corpse system for visualization.
|
|
||||||
"""
|
|
||||||
dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()]
|
dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()]
|
||||||
# Don't actually remove here - let the engine handle corpse visualization
|
|
||||||
return dead_agents
|
return dead_agents
|
||||||
|
|
||||||
def advance_time(self) -> None:
|
def advance_time(self) -> None:
|
||||||
@ -148,12 +245,14 @@ class World:
|
|||||||
self.step_in_day = 1
|
self.step_in_day = 1
|
||||||
self.current_day += 1
|
self.current_day += 1
|
||||||
|
|
||||||
# Determine time of day
|
|
||||||
if self.step_in_day <= self.config.day_steps:
|
if self.step_in_day <= self.config.day_steps:
|
||||||
self.time_of_day = TimeOfDay.DAY
|
self.time_of_day = TimeOfDay.DAY
|
||||||
else:
|
else:
|
||||||
self.time_of_day = TimeOfDay.NIGHT
|
self.time_of_day = TimeOfDay.NIGHT
|
||||||
|
|
||||||
|
# Update faction relations each turn
|
||||||
|
self.faction_relations.update_turn(self.current_turn)
|
||||||
|
|
||||||
def is_night(self) -> bool:
|
def is_night(self) -> bool:
|
||||||
"""Check if it's currently night."""
|
"""Check if it's currently night."""
|
||||||
return self.time_of_day == TimeOfDay.NIGHT
|
return self.time_of_day == TimeOfDay.NIGHT
|
||||||
@ -167,13 +266,25 @@ class World:
|
|||||||
living = self.get_living_agents()
|
living = self.get_living_agents()
|
||||||
total_money = sum(a.money for a in living)
|
total_money = sum(a.money for a in living)
|
||||||
|
|
||||||
# Count emergent professions (updated based on current skills)
|
# Count emergent professions
|
||||||
profession_counts = {}
|
profession_counts = {}
|
||||||
for agent in living:
|
for agent in living:
|
||||||
agent._update_profession() # Update based on current state
|
agent._update_profession()
|
||||||
prof = agent.profession.value
|
prof = agent.profession.value
|
||||||
profession_counts[prof] = profession_counts.get(prof, 0) + 1
|
profession_counts[prof] = profession_counts.get(prof, 0) + 1
|
||||||
|
|
||||||
|
# Count religions
|
||||||
|
religion_counts = {}
|
||||||
|
for agent in living:
|
||||||
|
rel = agent.religion.religion.value
|
||||||
|
religion_counts[rel] = religion_counts.get(rel, 0) + 1
|
||||||
|
|
||||||
|
# Count factions
|
||||||
|
faction_counts = {}
|
||||||
|
for agent in living:
|
||||||
|
fac = agent.diplomacy.faction.value
|
||||||
|
faction_counts[fac] = faction_counts.get(fac, 0) + 1
|
||||||
|
|
||||||
# Calculate wealth inequality metrics
|
# Calculate wealth inequality metrics
|
||||||
if living:
|
if living:
|
||||||
moneys = sorted([a.money for a in living])
|
moneys = sorted([a.money for a in living])
|
||||||
@ -182,15 +293,21 @@ class World:
|
|||||||
richest = moneys[-1] if moneys else 0
|
richest = moneys[-1] if moneys else 0
|
||||||
poorest = moneys[0] if moneys else 0
|
poorest = moneys[0] if moneys else 0
|
||||||
|
|
||||||
# Gini coefficient for inequality (0 = perfect equality, 1 = max inequality)
|
# Gini coefficient
|
||||||
n = len(moneys)
|
n = len(moneys)
|
||||||
if n > 1 and total_money > 0:
|
if n > 1 and total_money > 0:
|
||||||
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
||||||
gini = sum_of_diffs / (2 * n * total_money)
|
gini = sum_of_diffs / (2 * n * total_money)
|
||||||
else:
|
else:
|
||||||
gini = 0
|
gini = 0
|
||||||
|
|
||||||
|
# Average faith
|
||||||
|
avg_faith = sum(a.stats.faith for a in living) / len(living)
|
||||||
else:
|
else:
|
||||||
avg_money = median_money = richest = poorest = gini = 0
|
avg_money = median_money = richest = poorest = gini = avg_faith = 0
|
||||||
|
|
||||||
|
# War status
|
||||||
|
active_wars = len(self.faction_relations.active_wars)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"current_turn": self.current_turn,
|
"current_turn": self.current_turn,
|
||||||
@ -202,12 +319,20 @@ class World:
|
|||||||
"total_agents_died": self.total_agents_died,
|
"total_agents_died": self.total_agents_died,
|
||||||
"total_money_in_circulation": total_money,
|
"total_money_in_circulation": total_money,
|
||||||
"professions": profession_counts,
|
"professions": profession_counts,
|
||||||
# Wealth inequality metrics
|
# Wealth metrics
|
||||||
"avg_money": round(avg_money, 1),
|
"avg_money": round(avg_money, 1),
|
||||||
"median_money": median_money,
|
"median_money": median_money,
|
||||||
"richest_agent": richest,
|
"richest_agent": richest,
|
||||||
"poorest_agent": poorest,
|
"poorest_agent": poorest,
|
||||||
"gini_coefficient": round(gini, 3),
|
"gini_coefficient": round(gini, 3),
|
||||||
|
# NEW: Religion and diplomacy stats
|
||||||
|
"religions": religion_counts,
|
||||||
|
"factions": faction_counts,
|
||||||
|
"active_wars": active_wars,
|
||||||
|
"avg_faith": round(avg_faith, 1),
|
||||||
|
"total_wars": self.total_wars,
|
||||||
|
"total_peace_treaties": self.total_peace_treaties,
|
||||||
|
"total_conversions": self.total_conversions,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_state_snapshot(self) -> dict:
|
def get_state_snapshot(self) -> dict:
|
||||||
@ -220,21 +345,34 @@ class World:
|
|||||||
"world_size": {"width": self.config.width, "height": self.config.height},
|
"world_size": {"width": self.config.width, "height": self.config.height},
|
||||||
"agents": [a.to_dict() for a in self.agents],
|
"agents": [a.to_dict() for a in self.agents],
|
||||||
"statistics": self.get_statistics(),
|
"statistics": self.get_statistics(),
|
||||||
|
# NEW: Special locations
|
||||||
|
"oil_fields": [
|
||||||
|
{"name": l.name, "position": l.position.to_dict(), "faction": l.faction.value if l.faction else None}
|
||||||
|
for l in self.oil_fields
|
||||||
|
],
|
||||||
|
"temples": [
|
||||||
|
{"name": l.name, "position": l.position.to_dict(), "religion": l.religion.value if l.religion else None}
|
||||||
|
for l in self.temples
|
||||||
|
],
|
||||||
|
# NEW: Faction relations summary
|
||||||
|
"faction_relations": self.faction_relations.to_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def initialize(self) -> None:
|
def initialize(self) -> None:
|
||||||
"""Initialize the world with diverse starting agents.
|
"""Initialize the world with diverse starting agents.
|
||||||
|
|
||||||
Creates a mix of agent archetypes to seed profession diversity:
|
Creates a mix of agent archetypes to seed profession diversity.
|
||||||
- Some hunters (risk-takers who hunt)
|
Now also seeds religious and faction diversity.
|
||||||
- Some gatherers (cautious resource collectors)
|
|
||||||
- Some traders (market-focused wealth builders)
|
|
||||||
- Some generalists (balanced approach)
|
|
||||||
"""
|
"""
|
||||||
|
# Reset faction relations
|
||||||
|
self.faction_relations = reset_faction_relations()
|
||||||
|
|
||||||
|
# Generate special locations
|
||||||
|
self._generate_locations()
|
||||||
|
|
||||||
n = self.config.initial_agents
|
n = self.config.initial_agents
|
||||||
|
|
||||||
# Distribute archetypes for diversity
|
# Distribute archetypes for diversity
|
||||||
# ~15% hunters, ~15% gatherers, ~15% traders, ~10% woodcutters, 45% random
|
|
||||||
archetypes = (
|
archetypes = (
|
||||||
["hunter"] * max(1, n // 7) +
|
["hunter"] * max(1, n // 7) +
|
||||||
["gatherer"] * max(1, n // 7) +
|
["gatherer"] * max(1, n // 7) +
|
||||||
@ -242,13 +380,33 @@ class World:
|
|||||||
["woodcutter"] * max(1, n // 10)
|
["woodcutter"] * max(1, n // 10)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fill remaining slots with random (no archetype)
|
|
||||||
while len(archetypes) < n:
|
while len(archetypes) < n:
|
||||||
archetypes.append(None)
|
archetypes.append(None)
|
||||||
|
|
||||||
# Shuffle to randomize positions
|
|
||||||
random.shuffle(archetypes)
|
random.shuffle(archetypes)
|
||||||
|
|
||||||
for archetype in archetypes:
|
for archetype in archetypes:
|
||||||
self.spawn_agent(archetype=archetype)
|
self.spawn_agent(archetype=archetype)
|
||||||
|
|
||||||
|
# Set up some initial faction tensions for drama
|
||||||
|
self._create_initial_tensions()
|
||||||
|
|
||||||
|
def _create_initial_tensions(self) -> None:
|
||||||
|
"""Create some initial diplomatic tensions for realistic starting conditions."""
|
||||||
|
# Some factions have historical rivalries
|
||||||
|
rivalries = [
|
||||||
|
(FactionType.NORTHLANDS, FactionType.RIVERFOLK, -15),
|
||||||
|
(FactionType.FORESTKIN, FactionType.MOUNTAINEER, -10),
|
||||||
|
]
|
||||||
|
|
||||||
|
for faction1, faction2, modifier in rivalries:
|
||||||
|
self.faction_relations.modify_relation(faction1, faction2, modifier)
|
||||||
|
|
||||||
|
# Some factions have good relations
|
||||||
|
friendships = [
|
||||||
|
(FactionType.RIVERFOLK, FactionType.PLAINSMEN, 10),
|
||||||
|
(FactionType.PLAINSMEN, FactionType.FORESTKIN, 15),
|
||||||
|
]
|
||||||
|
|
||||||
|
for faction1, faction2, modifier in friendships:
|
||||||
|
self.faction_relations.modify_relation(faction1, faction2, modifier)
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
from .resources import ResourceType, Resource, RESOURCE_EFFECTS
|
from .resources import ResourceType, Resource, RESOURCE_EFFECTS
|
||||||
from .action import ActionType, ActionConfig, ActionResult, ACTION_CONFIG
|
from .action import ActionType, ActionConfig, ActionResult, ACTION_CONFIG
|
||||||
from .agent import Agent, AgentStats, Position
|
from .agent import Agent, AgentStats, Position
|
||||||
|
from .religion import ReligionType, ReligiousBeliefs
|
||||||
|
from .diplomacy import FactionType, AgentDiplomacy, FactionRelations
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ResourceType",
|
"ResourceType",
|
||||||
@ -15,5 +17,11 @@ __all__ = [
|
|||||||
"Agent",
|
"Agent",
|
||||||
"AgentStats",
|
"AgentStats",
|
||||||
"Position",
|
"Position",
|
||||||
|
# Religion
|
||||||
|
"ReligionType",
|
||||||
|
"ReligiousBeliefs",
|
||||||
|
# Diplomacy
|
||||||
|
"FactionType",
|
||||||
|
"AgentDiplomacy",
|
||||||
|
"FactionRelations",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class ActionType(Enum):
|
class ActionType(Enum):
|
||||||
"""Types of actions an agent can perform."""
|
"""Types of actions an agent can perform."""
|
||||||
|
# Basic survival actions
|
||||||
SLEEP = "sleep" # Night action - restores energy
|
SLEEP = "sleep" # Night action - restores energy
|
||||||
REST = "rest" # Day action - restores some energy
|
REST = "rest" # Day action - restores some energy
|
||||||
HUNT = "hunt" # Produces meat and hide
|
HUNT = "hunt" # Produces meat and hide
|
||||||
@ -26,6 +27,20 @@ class ActionType(Enum):
|
|||||||
TRADE = "trade" # Market interaction
|
TRADE = "trade" # Market interaction
|
||||||
CONSUME = "consume" # Consume resource from inventory
|
CONSUME = "consume" # Consume resource from inventory
|
||||||
|
|
||||||
|
# NEW: Oil industry actions
|
||||||
|
DRILL_OIL = "drill_oil" # Extract oil from oil fields
|
||||||
|
REFINE = "refine" # Convert oil to fuel
|
||||||
|
BURN_FUEL = "burn_fuel" # Use fuel for heat/energy
|
||||||
|
|
||||||
|
# NEW: Religious actions
|
||||||
|
PRAY = "pray" # Increase faith, slight energy cost
|
||||||
|
PREACH = "preach" # Spread religion, convert others
|
||||||
|
|
||||||
|
# NEW: Diplomatic actions
|
||||||
|
NEGOTIATE = "negotiate" # Improve relations with another faction
|
||||||
|
DECLARE_WAR = "declare_war" # Declare war on another faction
|
||||||
|
MAKE_PEACE = "make_peace" # Propose peace treaty
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ActionConfig:
|
class ActionConfig:
|
||||||
@ -40,14 +55,13 @@ class ActionConfig:
|
|||||||
secondary_max: int = 0
|
secondary_max: int = 0
|
||||||
requires_resource: Optional[ResourceType] = None
|
requires_resource: Optional[ResourceType] = None
|
||||||
requires_quantity: int = 0
|
requires_quantity: int = 0
|
||||||
|
# NEW: Faith effects
|
||||||
|
faith_gain: int = 0
|
||||||
|
faith_spread: int = 0
|
||||||
|
|
||||||
|
|
||||||
def get_action_config() -> dict[ActionType, ActionConfig]:
|
def get_action_config() -> dict[ActionType, ActionConfig]:
|
||||||
"""Get action configurations from the global config.
|
"""Get action configurations from the global config."""
|
||||||
|
|
||||||
This function dynamically builds ACTION_CONFIG from config.json values.
|
|
||||||
"""
|
|
||||||
# Import here to avoid circular imports
|
|
||||||
from backend.config import get_config
|
from backend.config import get_config
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
@ -55,10 +69,10 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ActionType.SLEEP: ActionConfig(
|
ActionType.SLEEP: ActionConfig(
|
||||||
energy_cost=actions.sleep_energy, # Restores energy
|
energy_cost=actions.sleep_energy,
|
||||||
),
|
),
|
||||||
ActionType.REST: ActionConfig(
|
ActionType.REST: ActionConfig(
|
||||||
energy_cost=actions.rest_energy, # Restores some energy
|
energy_cost=actions.rest_energy,
|
||||||
),
|
),
|
||||||
ActionType.HUNT: ActionConfig(
|
ActionType.HUNT: ActionConfig(
|
||||||
energy_cost=actions.hunt_energy,
|
energy_cost=actions.hunt_energy,
|
||||||
@ -112,6 +126,53 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
|
|||||||
ActionType.CONSUME: ActionConfig(
|
ActionType.CONSUME: ActionConfig(
|
||||||
energy_cost=0,
|
energy_cost=0,
|
||||||
),
|
),
|
||||||
|
# NEW: Oil industry actions
|
||||||
|
ActionType.DRILL_OIL: ActionConfig(
|
||||||
|
energy_cost=actions.drill_oil_energy,
|
||||||
|
success_chance=actions.drill_oil_success,
|
||||||
|
min_output=actions.drill_oil_min,
|
||||||
|
max_output=actions.drill_oil_max,
|
||||||
|
output_resource=ResourceType.OIL,
|
||||||
|
),
|
||||||
|
ActionType.REFINE: ActionConfig(
|
||||||
|
energy_cost=actions.refine_energy,
|
||||||
|
success_chance=1.0,
|
||||||
|
min_output=1,
|
||||||
|
max_output=1,
|
||||||
|
output_resource=ResourceType.FUEL,
|
||||||
|
requires_resource=ResourceType.OIL,
|
||||||
|
requires_quantity=2, # 2 oil -> 1 fuel
|
||||||
|
),
|
||||||
|
ActionType.BURN_FUEL: ActionConfig(
|
||||||
|
energy_cost=-1, # Minimal effort to burn fuel
|
||||||
|
success_chance=1.0,
|
||||||
|
requires_resource=ResourceType.FUEL,
|
||||||
|
requires_quantity=1,
|
||||||
|
),
|
||||||
|
# NEW: Religious actions
|
||||||
|
ActionType.PRAY: ActionConfig(
|
||||||
|
energy_cost=actions.pray_energy,
|
||||||
|
success_chance=1.0,
|
||||||
|
faith_gain=actions.pray_faith_gain,
|
||||||
|
),
|
||||||
|
ActionType.PREACH: ActionConfig(
|
||||||
|
energy_cost=actions.preach_energy,
|
||||||
|
success_chance=actions.preach_convert_chance,
|
||||||
|
faith_spread=actions.preach_faith_spread,
|
||||||
|
),
|
||||||
|
# NEW: Diplomatic actions
|
||||||
|
ActionType.NEGOTIATE: ActionConfig(
|
||||||
|
energy_cost=actions.negotiate_energy,
|
||||||
|
success_chance=0.7, # Not always successful
|
||||||
|
),
|
||||||
|
ActionType.DECLARE_WAR: ActionConfig(
|
||||||
|
energy_cost=actions.declare_war_energy,
|
||||||
|
success_chance=1.0, # Always succeeds (but has consequences)
|
||||||
|
),
|
||||||
|
ActionType.MAKE_PEACE: ActionConfig(
|
||||||
|
energy_cost=actions.make_peace_energy,
|
||||||
|
success_chance=0.5, # Harder to make peace than war
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -133,8 +194,6 @@ def reset_action_config_cache() -> None:
|
|||||||
_action_config_cache = None
|
_action_config_cache = None
|
||||||
|
|
||||||
|
|
||||||
# For backwards compatibility - this is a property-like access
|
|
||||||
# that returns fresh config each time (use get_cached_action_config for performance)
|
|
||||||
class _ActionConfigAccessor:
|
class _ActionConfigAccessor:
|
||||||
"""Accessor class that provides dict-like access to action configs."""
|
"""Accessor class that provides dict-like access to action configs."""
|
||||||
|
|
||||||
@ -161,6 +220,21 @@ class _ActionConfigAccessor:
|
|||||||
ACTION_CONFIG = _ActionConfigAccessor()
|
ACTION_CONFIG = _ActionConfigAccessor()
|
||||||
|
|
||||||
|
|
||||||
|
# Action categories for AI decision making
|
||||||
|
SURVIVAL_ACTIONS = {
|
||||||
|
ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||||
|
ActionType.GET_WATER, ActionType.BUILD_FIRE, ActionType.CONSUME
|
||||||
|
}
|
||||||
|
PRODUCTION_ACTIONS = {
|
||||||
|
ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||||
|
ActionType.GET_WATER, ActionType.DRILL_OIL
|
||||||
|
}
|
||||||
|
CRAFTING_ACTIONS = {ActionType.WEAVE, ActionType.REFINE}
|
||||||
|
RELIGIOUS_ACTIONS = {ActionType.PRAY, ActionType.PREACH}
|
||||||
|
DIPLOMATIC_ACTIONS = {ActionType.NEGOTIATE, ActionType.DECLARE_WAR, ActionType.MAKE_PEACE}
|
||||||
|
ECONOMIC_ACTIONS = {ActionType.TRADE}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ActionResult:
|
class ActionResult:
|
||||||
"""Result of executing an action."""
|
"""Result of executing an action."""
|
||||||
@ -170,8 +244,14 @@ class ActionResult:
|
|||||||
resources_gained: list = field(default_factory=list)
|
resources_gained: list = field(default_factory=list)
|
||||||
resources_consumed: list = field(default_factory=list)
|
resources_consumed: list = field(default_factory=list)
|
||||||
heat_gained: int = 0
|
heat_gained: int = 0
|
||||||
|
faith_gained: int = 0 # NEW
|
||||||
|
relation_change: int = 0 # NEW
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
# NEW: Diplomatic effects
|
||||||
|
target_faction: Optional[str] = None
|
||||||
|
diplomatic_effect: Optional[str] = None # "war", "peace", "improved", "degraded"
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary for API serialization."""
|
"""Convert to dictionary for API serialization."""
|
||||||
return {
|
return {
|
||||||
@ -187,5 +267,9 @@ class ActionResult:
|
|||||||
for r in self.resources_consumed
|
for r in self.resources_consumed
|
||||||
],
|
],
|
||||||
"heat_gained": self.heat_gained,
|
"heat_gained": self.heat_gained,
|
||||||
|
"faith_gained": self.faith_gained,
|
||||||
|
"relation_change": self.relation_change,
|
||||||
|
"target_faction": self.target_faction,
|
||||||
|
"diplomatic_effect": self.diplomatic_effect,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,9 @@
|
|||||||
Agent stats are loaded dynamically from the global config.
|
Agent stats are loaded dynamically from the global config.
|
||||||
Each agent now has unique personality traits and skills that create
|
Each agent now has unique personality traits and skills that create
|
||||||
emergent professions and behavioral diversity.
|
emergent professions and behavioral diversity.
|
||||||
|
|
||||||
|
NEW: Agents now have religion and faction membership for realistic
|
||||||
|
social dynamics including religious beliefs and geopolitical allegiances.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@ -17,6 +20,8 @@ from .personality import (
|
|||||||
PersonalityTraits, Skills, ProfessionType,
|
PersonalityTraits, Skills, ProfessionType,
|
||||||
determine_profession
|
determine_profession
|
||||||
)
|
)
|
||||||
|
from .religion import ReligiousBeliefs
|
||||||
|
from .diplomacy import AgentDiplomacy
|
||||||
|
|
||||||
|
|
||||||
def _get_agent_stats_config():
|
def _get_agent_stats_config():
|
||||||
@ -33,6 +38,8 @@ class Profession(Enum):
|
|||||||
WOODCUTTER = "woodcutter"
|
WOODCUTTER = "woodcutter"
|
||||||
TRADER = "trader"
|
TRADER = "trader"
|
||||||
CRAFTER = "crafter"
|
CRAFTER = "crafter"
|
||||||
|
OIL_WORKER = "oil_worker" # NEW: Oil industry worker
|
||||||
|
PRIEST = "priest" # NEW: Religious leader
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -80,18 +87,21 @@ class AgentStats:
|
|||||||
hunger: int = field(default=80)
|
hunger: int = field(default=80)
|
||||||
thirst: int = field(default=70)
|
thirst: int = field(default=70)
|
||||||
heat: int = field(default=100)
|
heat: int = field(default=100)
|
||||||
|
faith: int = field(default=50) # NEW: Religious faith level
|
||||||
|
|
||||||
# Maximum values - loaded from config
|
# Maximum values - loaded from config
|
||||||
MAX_ENERGY: int = field(default=50)
|
MAX_ENERGY: int = field(default=50)
|
||||||
MAX_HUNGER: int = field(default=100)
|
MAX_HUNGER: int = field(default=100)
|
||||||
MAX_THIRST: int = field(default=100)
|
MAX_THIRST: int = field(default=100)
|
||||||
MAX_HEAT: int = field(default=100)
|
MAX_HEAT: int = field(default=100)
|
||||||
|
MAX_FAITH: int = field(default=100) # NEW
|
||||||
|
|
||||||
# Passive decay rates per turn - loaded from config
|
# Passive decay rates per turn - loaded from config
|
||||||
ENERGY_DECAY: int = field(default=1)
|
ENERGY_DECAY: int = field(default=1)
|
||||||
HUNGER_DECAY: int = field(default=2)
|
HUNGER_DECAY: int = field(default=2)
|
||||||
THIRST_DECAY: int = field(default=3)
|
THIRST_DECAY: int = field(default=3)
|
||||||
HEAT_DECAY: int = field(default=2)
|
HEAT_DECAY: int = field(default=2)
|
||||||
|
FAITH_DECAY: int = field(default=1) # NEW
|
||||||
|
|
||||||
# Critical threshold - loaded from config
|
# Critical threshold - loaded from config
|
||||||
CRITICAL_THRESHOLD: float = field(default=0.25)
|
CRITICAL_THRESHOLD: float = field(default=0.25)
|
||||||
@ -106,6 +116,9 @@ class AgentStats:
|
|||||||
heat_decay = self.HEAT_DECAY // 2 if has_clothes else self.HEAT_DECAY
|
heat_decay = self.HEAT_DECAY // 2 if has_clothes else self.HEAT_DECAY
|
||||||
self.heat = max(0, self.heat - heat_decay)
|
self.heat = max(0, self.heat - heat_decay)
|
||||||
|
|
||||||
|
# Faith decays slowly - praying restores it
|
||||||
|
self.faith = max(0, self.faith - self.FAITH_DECAY)
|
||||||
|
|
||||||
def is_critical(self) -> bool:
|
def is_critical(self) -> bool:
|
||||||
"""Check if any vital stat is below critical threshold."""
|
"""Check if any vital stat is below critical threshold."""
|
||||||
threshold_hunger = int(self.MAX_HUNGER * self.CRITICAL_THRESHOLD)
|
threshold_hunger = int(self.MAX_HUNGER * self.CRITICAL_THRESHOLD)
|
||||||
@ -135,16 +148,31 @@ class AgentStats:
|
|||||||
"""Check if agent has enough energy to perform an action."""
|
"""Check if agent has enough energy to perform an action."""
|
||||||
return self.energy >= abs(energy_required)
|
return self.energy >= abs(energy_required)
|
||||||
|
|
||||||
|
def gain_faith(self, amount: int) -> None:
|
||||||
|
"""Increase faith level."""
|
||||||
|
self.faith = min(self.MAX_FAITH, self.faith + amount)
|
||||||
|
|
||||||
|
def lose_faith(self, amount: int) -> None:
|
||||||
|
"""Decrease faith level."""
|
||||||
|
self.faith = max(0, self.faith - amount)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_zealot(self) -> bool:
|
||||||
|
"""Check if agent has zealot-level faith."""
|
||||||
|
return self.faith >= int(self.MAX_FAITH * 0.80)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"energy": self.energy,
|
"energy": self.energy,
|
||||||
"hunger": self.hunger,
|
"hunger": self.hunger,
|
||||||
"thirst": self.thirst,
|
"thirst": self.thirst,
|
||||||
"heat": self.heat,
|
"heat": self.heat,
|
||||||
|
"faith": self.faith,
|
||||||
"max_energy": self.MAX_ENERGY,
|
"max_energy": self.MAX_ENERGY,
|
||||||
"max_hunger": self.MAX_HUNGER,
|
"max_hunger": self.MAX_HUNGER,
|
||||||
"max_thirst": self.MAX_THIRST,
|
"max_thirst": self.MAX_THIRST,
|
||||||
"max_heat": self.MAX_HEAT,
|
"max_heat": self.MAX_HEAT,
|
||||||
|
"max_faith": self.MAX_FAITH,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -156,14 +184,17 @@ def create_agent_stats() -> AgentStats:
|
|||||||
hunger=config.start_hunger,
|
hunger=config.start_hunger,
|
||||||
thirst=config.start_thirst,
|
thirst=config.start_thirst,
|
||||||
heat=config.start_heat,
|
heat=config.start_heat,
|
||||||
|
faith=getattr(config, 'start_faith', 50),
|
||||||
MAX_ENERGY=config.max_energy,
|
MAX_ENERGY=config.max_energy,
|
||||||
MAX_HUNGER=config.max_hunger,
|
MAX_HUNGER=config.max_hunger,
|
||||||
MAX_THIRST=config.max_thirst,
|
MAX_THIRST=config.max_thirst,
|
||||||
MAX_HEAT=config.max_heat,
|
MAX_HEAT=config.max_heat,
|
||||||
|
MAX_FAITH=getattr(config, 'max_faith', 100),
|
||||||
ENERGY_DECAY=config.energy_decay,
|
ENERGY_DECAY=config.energy_decay,
|
||||||
HUNGER_DECAY=config.hunger_decay,
|
HUNGER_DECAY=config.hunger_decay,
|
||||||
THIRST_DECAY=config.thirst_decay,
|
THIRST_DECAY=config.thirst_decay,
|
||||||
HEAT_DECAY=config.heat_decay,
|
HEAT_DECAY=config.heat_decay,
|
||||||
|
FAITH_DECAY=getattr(config, 'faith_decay', 1),
|
||||||
CRITICAL_THRESHOLD=config.critical_threshold,
|
CRITICAL_THRESHOLD=config.critical_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -171,9 +202,10 @@ def create_agent_stats() -> AgentStats:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AgentAction:
|
class AgentAction:
|
||||||
"""Current action being performed by an agent."""
|
"""Current action being performed by an agent."""
|
||||||
action_type: str = "" # e.g., "hunt", "gather", "trade", "rest"
|
action_type: str = "" # e.g., "hunt", "gather", "trade", "rest", "pray"
|
||||||
target_position: Optional[Position] = None
|
target_position: Optional[Position] = None
|
||||||
target_resource: Optional[str] = None
|
target_resource: Optional[str] = None
|
||||||
|
target_agent: Optional[str] = None # NEW: For diplomatic/religious actions
|
||||||
progress: float = 0.0 # 0.0 to 1.0
|
progress: float = 0.0 # 0.0 to 1.0
|
||||||
is_moving: bool = False
|
is_moving: bool = False
|
||||||
message: str = ""
|
message: str = ""
|
||||||
@ -183,6 +215,7 @@ class AgentAction:
|
|||||||
"action_type": self.action_type,
|
"action_type": self.action_type,
|
||||||
"target_position": self.target_position.to_dict() if self.target_position else None,
|
"target_position": self.target_position.to_dict() if self.target_position else None,
|
||||||
"target_resource": self.target_resource,
|
"target_resource": self.target_resource,
|
||||||
|
"target_agent": self.target_agent,
|
||||||
"progress": round(self.progress, 2),
|
"progress": round(self.progress, 2),
|
||||||
"is_moving": self.is_moving,
|
"is_moving": self.is_moving,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
@ -191,16 +224,27 @@ class AgentAction:
|
|||||||
|
|
||||||
# Action location mappings (relative positions on the map for each action type)
|
# Action location mappings (relative positions on the map for each action type)
|
||||||
ACTION_LOCATIONS = {
|
ACTION_LOCATIONS = {
|
||||||
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)}, # Right side (forest)
|
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)},
|
||||||
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)}, # Left side (bushes)
|
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)},
|
||||||
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)}, # Far right (forest)
|
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)},
|
||||||
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)}, # Far left (river)
|
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)},
|
||||||
"weave": {"zone": "village", "offset_range": (0.4, 0.6)}, # Center (village)
|
"weave": {"zone": "village", "offset_range": (0.4, 0.6)},
|
||||||
"build_fire": {"zone": "village", "offset_range": (0.45, 0.55)},
|
"build_fire": {"zone": "village", "offset_range": (0.45, 0.55)},
|
||||||
"trade": {"zone": "market", "offset_range": (0.5, 0.6)}, # Center (market)
|
"burn_fuel": {"zone": "village", "offset_range": (0.45, 0.55)},
|
||||||
|
"trade": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||||
"rest": {"zone": "home", "offset_range": (0.4, 0.6)},
|
"rest": {"zone": "home", "offset_range": (0.4, 0.6)},
|
||||||
"sleep": {"zone": "home", "offset_range": (0.4, 0.6)},
|
"sleep": {"zone": "home", "offset_range": (0.4, 0.6)},
|
||||||
"consume": {"zone": "current", "offset_range": (0, 0)}, # Stay in place
|
"consume": {"zone": "current", "offset_range": (0, 0)},
|
||||||
|
# NEW: Oil industry locations
|
||||||
|
"drill_oil": {"zone": "oil_field", "offset_range": (0.8, 0.95)},
|
||||||
|
"refine": {"zone": "refinery", "offset_range": (0.7, 0.85)},
|
||||||
|
# NEW: Religious locations
|
||||||
|
"pray": {"zone": "temple", "offset_range": (0.45, 0.55)},
|
||||||
|
"preach": {"zone": "village", "offset_range": (0.4, 0.6)},
|
||||||
|
# NEW: Diplomatic locations
|
||||||
|
"negotiate": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||||
|
"declare_war": {"zone": "village", "offset_range": (0.5, 0.5)},
|
||||||
|
"make_peace": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -217,57 +261,76 @@ class Agent:
|
|||||||
Stats, inventory slots, and starting money are loaded from config.json.
|
Stats, inventory slots, and starting money are loaded from config.json.
|
||||||
Each agent now has unique personality traits and skills that create
|
Each agent now has unique personality traits and skills that create
|
||||||
emergent behaviors and professions.
|
emergent behaviors and professions.
|
||||||
|
|
||||||
|
NEW: Agents now have religious beliefs and faction membership.
|
||||||
"""
|
"""
|
||||||
id: str = field(default_factory=lambda: str(uuid4())[:8])
|
id: str = field(default_factory=lambda: str(uuid4())[:8])
|
||||||
name: str = ""
|
name: str = ""
|
||||||
profession: Profession = Profession.VILLAGER # Now derived from personality/skills
|
profession: Profession = Profession.VILLAGER
|
||||||
position: Position = field(default_factory=Position)
|
position: Position = field(default_factory=Position)
|
||||||
stats: AgentStats = field(default_factory=create_agent_stats)
|
stats: AgentStats = field(default_factory=create_agent_stats)
|
||||||
inventory: list[Resource] = field(default_factory=list)
|
inventory: list[Resource] = field(default_factory=list)
|
||||||
money: int = field(default=-1) # -1 signals to use config value
|
money: int = field(default=-1)
|
||||||
|
|
||||||
# Personality and skills - create agent diversity
|
# Personality and skills
|
||||||
personality: PersonalityTraits = field(default_factory=PersonalityTraits)
|
personality: PersonalityTraits = field(default_factory=PersonalityTraits)
|
||||||
skills: Skills = field(default_factory=Skills)
|
skills: Skills = field(default_factory=Skills)
|
||||||
|
|
||||||
|
# NEW: Religion and diplomacy
|
||||||
|
religion: ReligiousBeliefs = field(default_factory=ReligiousBeliefs)
|
||||||
|
diplomacy: AgentDiplomacy = field(default_factory=AgentDiplomacy)
|
||||||
|
|
||||||
# Movement and action tracking
|
# Movement and action tracking
|
||||||
home_position: Position = field(default_factory=Position)
|
home_position: Position = field(default_factory=Position)
|
||||||
current_action: AgentAction = field(default_factory=AgentAction)
|
current_action: AgentAction = field(default_factory=AgentAction)
|
||||||
last_action_result: str = ""
|
last_action_result: str = ""
|
||||||
|
|
||||||
# Death tracking for corpse visualization
|
# Death tracking for corpse visualization
|
||||||
death_turn: int = -1 # Turn when agent died, -1 if alive
|
death_turn: int = -1
|
||||||
death_reason: str = "" # Cause of death
|
death_reason: str = ""
|
||||||
|
|
||||||
# Statistics tracking for profession determination
|
# Statistics tracking for profession determination
|
||||||
actions_performed: dict = field(default_factory=lambda: {
|
actions_performed: dict = field(default_factory=lambda: {
|
||||||
"hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0
|
"hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0,
|
||||||
|
"drill_oil": 0, "refine": 0, "pray": 0, "preach": 0,
|
||||||
|
"negotiate": 0, "declare_war": 0, "make_peace": 0,
|
||||||
})
|
})
|
||||||
total_trades_completed: int = 0
|
total_trades_completed: int = 0
|
||||||
total_money_earned: int = 0
|
total_money_earned: int = 0
|
||||||
|
|
||||||
# Configuration - loaded from config
|
# Configuration - loaded from config
|
||||||
INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value
|
INVENTORY_SLOTS: int = field(default=-1)
|
||||||
MOVE_SPEED: float = 0.8 # Grid cells per turn
|
MOVE_SPEED: float = 0.8
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not self.name:
|
if not self.name:
|
||||||
self.name = f"Agent_{self.id}"
|
self.name = f"Agent_{self.id}"
|
||||||
# Set home position to initial position
|
|
||||||
self.home_position = self.position.copy()
|
self.home_position = self.position.copy()
|
||||||
|
|
||||||
# Load config values if defaults were used
|
|
||||||
config = _get_world_config()
|
config = _get_world_config()
|
||||||
if self.money == -1:
|
if self.money == -1:
|
||||||
self.money = config.starting_money
|
self.money = config.starting_money
|
||||||
if self.INVENTORY_SLOTS == -1:
|
if self.INVENTORY_SLOTS == -1:
|
||||||
self.INVENTORY_SLOTS = config.inventory_slots
|
self.INVENTORY_SLOTS = config.inventory_slots
|
||||||
|
|
||||||
# Update profession based on personality and skills
|
|
||||||
self._update_profession()
|
self._update_profession()
|
||||||
|
|
||||||
def _update_profession(self) -> None:
|
def _update_profession(self) -> None:
|
||||||
"""Update profession based on personality and skills."""
|
"""Update profession based on personality, skills, and activities."""
|
||||||
|
# Check for specialized professions first
|
||||||
|
|
||||||
|
# High religious activity = Priest
|
||||||
|
if self.actions_performed.get("pray", 0) + self.actions_performed.get("preach", 0) > 10:
|
||||||
|
if self.stats.faith > 70:
|
||||||
|
self.profession = Profession.PRIEST
|
||||||
|
return
|
||||||
|
|
||||||
|
# High oil activity = Oil Worker
|
||||||
|
if self.actions_performed.get("drill_oil", 0) + self.actions_performed.get("refine", 0) > 10:
|
||||||
|
self.profession = Profession.OIL_WORKER
|
||||||
|
return
|
||||||
|
|
||||||
|
# Standard profession determination
|
||||||
prof_type = determine_profession(self.personality, self.skills)
|
prof_type = determine_profession(self.personality, self.skills)
|
||||||
profession_map = {
|
profession_map = {
|
||||||
ProfessionType.HUNTER: Profession.HUNTER,
|
ProfessionType.HUNTER: Profession.HUNTER,
|
||||||
@ -298,7 +361,7 @@ class Agent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def is_corpse(self) -> bool:
|
def is_corpse(self) -> bool:
|
||||||
"""Check if this agent is a corpse (died but still visible)."""
|
"""Check if this agent is a corpse."""
|
||||||
return self.death_turn >= 0
|
return self.death_turn >= 0
|
||||||
|
|
||||||
def can_act(self) -> bool:
|
def can_act(self) -> bool:
|
||||||
@ -309,6 +372,14 @@ class Agent:
|
|||||||
"""Check if agent has clothes equipped."""
|
"""Check if agent has clothes equipped."""
|
||||||
return any(r.type == ResourceType.CLOTHES for r in self.inventory)
|
return any(r.type == ResourceType.CLOTHES for r in self.inventory)
|
||||||
|
|
||||||
|
def has_oil(self) -> bool:
|
||||||
|
"""Check if agent has oil."""
|
||||||
|
return any(r.type == ResourceType.OIL for r in self.inventory)
|
||||||
|
|
||||||
|
def has_fuel(self) -> bool:
|
||||||
|
"""Check if agent has fuel."""
|
||||||
|
return any(r.type == ResourceType.FUEL for r in self.inventory)
|
||||||
|
|
||||||
def inventory_space(self) -> int:
|
def inventory_space(self) -> int:
|
||||||
"""Get remaining inventory slots."""
|
"""Get remaining inventory slots."""
|
||||||
total_items = sum(r.quantity for r in self.inventory)
|
total_items = sum(r.quantity for r in self.inventory)
|
||||||
@ -325,22 +396,20 @@ class Agent:
|
|||||||
world_height: int,
|
world_height: int,
|
||||||
message: str = "",
|
message: str = "",
|
||||||
target_resource: Optional[str] = None,
|
target_resource: Optional[str] = None,
|
||||||
|
target_agent: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the current action and calculate target position."""
|
"""Set the current action and calculate target position."""
|
||||||
location = ACTION_LOCATIONS.get(action_type, {"zone": "current", "offset_range": (0, 0)})
|
location = ACTION_LOCATIONS.get(action_type, {"zone": "current", "offset_range": (0, 0)})
|
||||||
|
|
||||||
if location["zone"] == "current":
|
if location["zone"] == "current":
|
||||||
# Stay in place
|
|
||||||
target = self.position.copy()
|
target = self.position.copy()
|
||||||
is_moving = False
|
is_moving = False
|
||||||
else:
|
else:
|
||||||
# Calculate target position based on action zone
|
|
||||||
offset_range = location["offset_range"]
|
offset_range = location["offset_range"]
|
||||||
offset_min = float(offset_range[0])
|
offset_min = float(offset_range[0]) if offset_range else 0.0
|
||||||
offset_max = float(offset_range[1])
|
offset_max = float(offset_range[1]) if offset_range else 0.0
|
||||||
target_x = world_width * random.uniform(offset_min, offset_max)
|
target_x = world_width * random.uniform(offset_min, offset_max)
|
||||||
|
|
||||||
# Keep y position somewhat consistent but allow some variation
|
|
||||||
target_y = self.home_position.y + random.uniform(-2, 2)
|
target_y = self.home_position.y + random.uniform(-2, 2)
|
||||||
target_y = max(0.5, min(world_height - 0.5, target_y))
|
target_y = max(0.5, min(world_height - 0.5, target_y))
|
||||||
|
|
||||||
@ -351,6 +420,7 @@ class Agent:
|
|||||||
action_type=action_type,
|
action_type=action_type,
|
||||||
target_position=target,
|
target_position=target,
|
||||||
target_resource=target_resource,
|
target_resource=target_resource,
|
||||||
|
target_agent=target_agent,
|
||||||
progress=0.0,
|
progress=0.0,
|
||||||
is_moving=is_moving,
|
is_moving=is_moving,
|
||||||
message=message,
|
message=message,
|
||||||
@ -365,7 +435,7 @@ class Agent:
|
|||||||
)
|
)
|
||||||
if reached:
|
if reached:
|
||||||
self.current_action.is_moving = False
|
self.current_action.is_moving = False
|
||||||
self.current_action.progress = 0.5 # At location, doing action
|
self.current_action.progress = 0.5
|
||||||
|
|
||||||
def complete_action(self, success: bool, message: str) -> None:
|
def complete_action(self, success: bool, message: str) -> None:
|
||||||
"""Mark current action as complete."""
|
"""Mark current action as complete."""
|
||||||
@ -382,13 +452,11 @@ class Agent:
|
|||||||
|
|
||||||
quantity_to_add = min(resource.quantity, space)
|
quantity_to_add = min(resource.quantity, space)
|
||||||
|
|
||||||
# Try to stack with existing resource of same type
|
|
||||||
for existing in self.inventory:
|
for existing in self.inventory:
|
||||||
if existing.type == resource.type:
|
if existing.type == resource.type:
|
||||||
existing.quantity += quantity_to_add
|
existing.quantity += quantity_to_add
|
||||||
return quantity_to_add
|
return quantity_to_add
|
||||||
|
|
||||||
# Add as new stack
|
|
||||||
new_resource = Resource(
|
new_resource = Resource(
|
||||||
type=resource.type,
|
type=resource.type,
|
||||||
quantity=quantity_to_add,
|
quantity=quantity_to_add,
|
||||||
@ -452,7 +520,7 @@ class Agent:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def apply_heat(self, amount: int) -> None:
|
def apply_heat(self, amount: int) -> None:
|
||||||
"""Apply heat from a fire."""
|
"""Apply heat from a fire or fuel."""
|
||||||
self.stats.heat = min(self.stats.MAX_HEAT, self.stats.heat + amount)
|
self.stats.heat = min(self.stats.MAX_HEAT, self.stats.heat + amount)
|
||||||
|
|
||||||
def restore_energy(self, amount: int) -> None:
|
def restore_energy(self, amount: int) -> None:
|
||||||
@ -466,8 +534,13 @@ class Agent:
|
|||||||
self.stats.energy -= amount
|
self.stats.energy -= amount
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def gain_faith(self, amount: int) -> None:
|
||||||
|
"""Increase faith from religious activity."""
|
||||||
|
self.stats.gain_faith(amount)
|
||||||
|
self.religion.gain_faith(amount)
|
||||||
|
|
||||||
def decay_inventory(self, current_turn: int) -> list[Resource]:
|
def decay_inventory(self, current_turn: int) -> list[Resource]:
|
||||||
"""Remove expired resources from inventory. Returns list of removed resources."""
|
"""Remove expired resources from inventory."""
|
||||||
expired = []
|
expired = []
|
||||||
for resource in self.inventory[:]:
|
for resource in self.inventory[:]:
|
||||||
if resource.is_expired(current_turn):
|
if resource.is_expired(current_turn):
|
||||||
@ -478,15 +551,38 @@ class Agent:
|
|||||||
def apply_passive_decay(self) -> None:
|
def apply_passive_decay(self) -> None:
|
||||||
"""Apply passive stat decay for this turn."""
|
"""Apply passive stat decay for this turn."""
|
||||||
self.stats.apply_passive_decay(has_clothes=self.has_clothes())
|
self.stats.apply_passive_decay(has_clothes=self.has_clothes())
|
||||||
|
self.religion.apply_decay()
|
||||||
|
|
||||||
def mark_dead(self, turn: int, reason: str) -> None:
|
def mark_dead(self, turn: int, reason: str) -> None:
|
||||||
"""Mark this agent as dead."""
|
"""Mark this agent as dead."""
|
||||||
self.death_turn = turn
|
self.death_turn = turn
|
||||||
self.death_reason = reason
|
self.death_reason = reason
|
||||||
|
|
||||||
|
def shares_religion_with(self, other: "Agent") -> bool:
|
||||||
|
"""Check if agent shares religion with another."""
|
||||||
|
return self.religion.religion == other.religion.religion
|
||||||
|
|
||||||
|
def shares_faction_with(self, other: "Agent") -> bool:
|
||||||
|
"""Check if agent shares faction with another."""
|
||||||
|
return self.diplomacy.faction == other.diplomacy.faction
|
||||||
|
|
||||||
|
def get_trade_modifier_for(self, other: "Agent") -> float:
|
||||||
|
"""Get combined trade modifier when trading with another agent."""
|
||||||
|
# Religion modifier
|
||||||
|
religion_mod = self.religion.get_trade_modifier(other.religion)
|
||||||
|
|
||||||
|
# Faction modifier (from global relations)
|
||||||
|
from .diplomacy import get_faction_relations
|
||||||
|
faction_relations = get_faction_relations()
|
||||||
|
faction_mod = faction_relations.get_trade_modifier(
|
||||||
|
self.diplomacy.faction,
|
||||||
|
other.diplomacy.faction
|
||||||
|
)
|
||||||
|
|
||||||
|
return religion_mod * faction_mod
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary for API serialization."""
|
"""Convert to dictionary for API serialization."""
|
||||||
# Update profession before serializing
|
|
||||||
self._update_profession()
|
self._update_profession()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -505,10 +601,13 @@ class Agent:
|
|||||||
"last_action_result": self.last_action_result,
|
"last_action_result": self.last_action_result,
|
||||||
"death_turn": self.death_turn,
|
"death_turn": self.death_turn,
|
||||||
"death_reason": self.death_reason,
|
"death_reason": self.death_reason,
|
||||||
# New fields for agent diversity
|
# Personality and skills
|
||||||
"personality": self.personality.to_dict(),
|
"personality": self.personality.to_dict(),
|
||||||
"skills": self.skills.to_dict(),
|
"skills": self.skills.to_dict(),
|
||||||
"actions_performed": self.actions_performed.copy(),
|
"actions_performed": self.actions_performed.copy(),
|
||||||
"total_trades": self.total_trades_completed,
|
"total_trades": self.total_trades_completed,
|
||||||
"total_money_earned": self.total_money_earned,
|
"total_money_earned": self.total_money_earned,
|
||||||
|
# NEW: Religion and diplomacy
|
||||||
|
"religion": self.religion.to_dict(),
|
||||||
|
"diplomacy": self.diplomacy.to_dict(),
|
||||||
}
|
}
|
||||||
|
|||||||
515
backend/domain/diplomacy.py
Normal file
515
backend/domain/diplomacy.py
Normal file
@ -0,0 +1,515 @@
|
|||||||
|
"""Diplomacy system for the Village Simulation.
|
||||||
|
|
||||||
|
Creates faction-based politics with:
|
||||||
|
- Multiple factions that agents belong to
|
||||||
|
- Relations between factions (0-100)
|
||||||
|
- War and peace mechanics
|
||||||
|
- Trade agreements and alliances
|
||||||
|
- Real-world style geopolitics
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Dict, Set
|
||||||
|
|
||||||
|
|
||||||
|
class FactionType(Enum):
|
||||||
|
"""Types of factions in the simulation.
|
||||||
|
|
||||||
|
Like real-world nations/groups with distinct characteristics.
|
||||||
|
"""
|
||||||
|
NEUTRAL = "neutral" # Unaffiliated agents
|
||||||
|
NORTHLANDS = "northlands" # Northern faction - hardy, value warmth
|
||||||
|
RIVERFOLK = "riverfolk" # River faction - trade-focused, value water
|
||||||
|
FORESTKIN = "forestkin" # Forest faction - hunters and gatherers
|
||||||
|
MOUNTAINEER = "mountaineer" # Mountain faction - miners, value resources
|
||||||
|
PLAINSMEN = "plainsmen" # Plains faction - farmers, balanced
|
||||||
|
|
||||||
|
|
||||||
|
# Faction characteristics
|
||||||
|
FACTION_TRAITS = {
|
||||||
|
FactionType.NEUTRAL: {
|
||||||
|
"description": "Unaffiliated individuals",
|
||||||
|
"bonus_resource": None,
|
||||||
|
"aggression": 0.0,
|
||||||
|
"diplomacy_skill": 0.5,
|
||||||
|
"trade_preference": 1.0,
|
||||||
|
"color": "#808080",
|
||||||
|
},
|
||||||
|
FactionType.NORTHLANDS: {
|
||||||
|
"description": "Hardy people of the North",
|
||||||
|
"bonus_resource": "wood", # Wood for warmth
|
||||||
|
"aggression": 0.4,
|
||||||
|
"diplomacy_skill": 0.6,
|
||||||
|
"trade_preference": 0.8,
|
||||||
|
"color": "#4A90D9",
|
||||||
|
},
|
||||||
|
FactionType.RIVERFOLK: {
|
||||||
|
"description": "Traders of the Rivers",
|
||||||
|
"bonus_resource": "water",
|
||||||
|
"aggression": 0.2,
|
||||||
|
"diplomacy_skill": 0.9, # Best diplomats
|
||||||
|
"trade_preference": 1.5, # Love trading
|
||||||
|
"color": "#2E8B57",
|
||||||
|
},
|
||||||
|
FactionType.FORESTKIN: {
|
||||||
|
"description": "Hunters of the Forest",
|
||||||
|
"bonus_resource": "meat",
|
||||||
|
"aggression": 0.5,
|
||||||
|
"diplomacy_skill": 0.5,
|
||||||
|
"trade_preference": 0.9,
|
||||||
|
"color": "#228B22",
|
||||||
|
},
|
||||||
|
FactionType.MOUNTAINEER: {
|
||||||
|
"description": "Miners of the Mountains",
|
||||||
|
"bonus_resource": "oil", # Control oil fields
|
||||||
|
"aggression": 0.3,
|
||||||
|
"diplomacy_skill": 0.7,
|
||||||
|
"trade_preference": 1.2,
|
||||||
|
"color": "#8B4513",
|
||||||
|
},
|
||||||
|
FactionType.PLAINSMEN: {
|
||||||
|
"description": "Farmers of the Plains",
|
||||||
|
"bonus_resource": "berries",
|
||||||
|
"aggression": 0.25,
|
||||||
|
"diplomacy_skill": 0.6,
|
||||||
|
"trade_preference": 1.0,
|
||||||
|
"color": "#DAA520",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DiplomaticStatus(Enum):
|
||||||
|
"""Current diplomatic status between factions."""
|
||||||
|
WAR = "war" # Active conflict
|
||||||
|
HOSTILE = "hostile" # Near-war tensions
|
||||||
|
COLD = "cold" # Cool relations
|
||||||
|
NEUTRAL = "neutral" # Default state
|
||||||
|
FRIENDLY = "friendly" # Good relations
|
||||||
|
ALLIED = "allied" # Full alliance
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Treaty:
|
||||||
|
"""A diplomatic treaty between factions."""
|
||||||
|
faction1: FactionType
|
||||||
|
faction2: FactionType
|
||||||
|
treaty_type: str # "peace", "trade", "alliance"
|
||||||
|
start_turn: int
|
||||||
|
duration: int
|
||||||
|
terms: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def is_active(self, current_turn: int) -> bool:
|
||||||
|
"""Check if treaty is still active."""
|
||||||
|
if self.duration <= 0: # Permanent
|
||||||
|
return True
|
||||||
|
return current_turn < self.start_turn + self.duration
|
||||||
|
|
||||||
|
def turns_remaining(self, current_turn: int) -> int:
|
||||||
|
"""Get turns remaining in treaty."""
|
||||||
|
if self.duration <= 0:
|
||||||
|
return -1 # Permanent
|
||||||
|
return max(0, (self.start_turn + self.duration) - current_turn)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"faction1": self.faction1.value,
|
||||||
|
"faction2": self.faction2.value,
|
||||||
|
"treaty_type": self.treaty_type,
|
||||||
|
"start_turn": self.start_turn,
|
||||||
|
"duration": self.duration,
|
||||||
|
"terms": self.terms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FactionRelations:
|
||||||
|
"""Manages relations between all factions."""
|
||||||
|
# Relations matrix (faction -> faction -> relation value 0-100)
|
||||||
|
relations: Dict[FactionType, Dict[FactionType, int]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Active wars
|
||||||
|
active_wars: Set[tuple] = field(default_factory=set)
|
||||||
|
|
||||||
|
# Active treaties
|
||||||
|
treaties: list = field(default_factory=list)
|
||||||
|
|
||||||
|
# War exhaustion per faction
|
||||||
|
war_exhaustion: Dict[FactionType, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self._initialize_relations()
|
||||||
|
|
||||||
|
def _initialize_relations(self) -> None:
|
||||||
|
"""Initialize default relations between all factions."""
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
starting = config.diplomacy.starting_relations
|
||||||
|
|
||||||
|
for faction1 in FactionType:
|
||||||
|
if faction1 not in self.relations:
|
||||||
|
self.relations[faction1] = {}
|
||||||
|
if faction1 not in self.war_exhaustion:
|
||||||
|
self.war_exhaustion[faction1] = 0
|
||||||
|
|
||||||
|
for faction2 in FactionType:
|
||||||
|
if faction2 not in self.relations[faction1]:
|
||||||
|
if faction1 == faction2:
|
||||||
|
self.relations[faction1][faction2] = 100 # Perfect self-relations
|
||||||
|
else:
|
||||||
|
self.relations[faction1][faction2] = starting
|
||||||
|
|
||||||
|
def get_relation(self, faction1: FactionType, faction2: FactionType) -> int:
|
||||||
|
"""Get relation value between two factions (0-100)."""
|
||||||
|
if faction1 not in self.relations:
|
||||||
|
self._initialize_relations()
|
||||||
|
return self.relations.get(faction1, {}).get(faction2, 50)
|
||||||
|
|
||||||
|
def get_status(self, faction1: FactionType, faction2: FactionType) -> DiplomaticStatus:
|
||||||
|
"""Get diplomatic status between factions."""
|
||||||
|
if faction1 == faction2:
|
||||||
|
return DiplomaticStatus.ALLIED
|
||||||
|
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Check for active war
|
||||||
|
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||||
|
if war_pair in self.active_wars:
|
||||||
|
return DiplomaticStatus.WAR
|
||||||
|
|
||||||
|
relation = self.get_relation(faction1, faction2)
|
||||||
|
|
||||||
|
if relation >= config.diplomacy.alliance_threshold:
|
||||||
|
return DiplomaticStatus.ALLIED
|
||||||
|
elif relation >= 65:
|
||||||
|
return DiplomaticStatus.FRIENDLY
|
||||||
|
elif relation >= 40:
|
||||||
|
return DiplomaticStatus.NEUTRAL
|
||||||
|
elif relation >= config.diplomacy.war_threshold:
|
||||||
|
return DiplomaticStatus.COLD
|
||||||
|
else:
|
||||||
|
return DiplomaticStatus.HOSTILE
|
||||||
|
|
||||||
|
def modify_relation(self, faction1: FactionType, faction2: FactionType, amount: int) -> int:
|
||||||
|
"""Modify relation between factions (symmetric)."""
|
||||||
|
if faction1 == faction2:
|
||||||
|
return 100
|
||||||
|
|
||||||
|
if faction1 not in self.relations:
|
||||||
|
self._initialize_relations()
|
||||||
|
|
||||||
|
# Modify symmetrically
|
||||||
|
current1 = self.relations[faction1].get(faction2, 50)
|
||||||
|
current2 = self.relations[faction2].get(faction1, 50)
|
||||||
|
|
||||||
|
new_value1 = max(0, min(100, current1 + amount))
|
||||||
|
new_value2 = max(0, min(100, current2 + amount))
|
||||||
|
|
||||||
|
self.relations[faction1][faction2] = new_value1
|
||||||
|
self.relations[faction2][faction1] = new_value2
|
||||||
|
|
||||||
|
return new_value1
|
||||||
|
|
||||||
|
def declare_war(self, aggressor: FactionType, defender: FactionType, turn: int) -> bool:
|
||||||
|
"""Declare war between factions."""
|
||||||
|
if aggressor == defender:
|
||||||
|
return False
|
||||||
|
if aggressor == FactionType.NEUTRAL or defender == FactionType.NEUTRAL:
|
||||||
|
return False
|
||||||
|
|
||||||
|
war_pair = tuple(sorted([aggressor.value, defender.value]))
|
||||||
|
if war_pair in self.active_wars:
|
||||||
|
return False # Already at war
|
||||||
|
|
||||||
|
self.active_wars.add(war_pair)
|
||||||
|
|
||||||
|
# Relations plummet
|
||||||
|
self.modify_relation(aggressor, defender, -50)
|
||||||
|
|
||||||
|
# Cancel any treaties
|
||||||
|
self.treaties = [
|
||||||
|
t for t in self.treaties
|
||||||
|
if not (t.faction1 in (aggressor, defender) and t.faction2 in (aggressor, defender))
|
||||||
|
]
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def make_peace(self, faction1: FactionType, faction2: FactionType, turn: int) -> bool:
|
||||||
|
"""Make peace between warring factions."""
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||||
|
if war_pair not in self.active_wars:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.active_wars.remove(war_pair)
|
||||||
|
|
||||||
|
# Create peace treaty
|
||||||
|
treaty = Treaty(
|
||||||
|
faction1=faction1,
|
||||||
|
faction2=faction2,
|
||||||
|
treaty_type="peace",
|
||||||
|
start_turn=turn,
|
||||||
|
duration=config.diplomacy.peace_treaty_duration,
|
||||||
|
)
|
||||||
|
self.treaties.append(treaty)
|
||||||
|
|
||||||
|
# Improve relations slightly
|
||||||
|
self.modify_relation(faction1, faction2, 15)
|
||||||
|
|
||||||
|
# Reset war exhaustion
|
||||||
|
self.war_exhaustion[faction1] = 0
|
||||||
|
self.war_exhaustion[faction2] = 0
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def form_alliance(self, faction1: FactionType, faction2: FactionType, turn: int) -> bool:
|
||||||
|
"""Form an alliance between factions."""
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if faction1 == faction2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
relation = self.get_relation(faction1, faction2)
|
||||||
|
if relation < config.diplomacy.alliance_threshold:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check not already allied
|
||||||
|
for treaty in self.treaties:
|
||||||
|
if treaty.treaty_type == "alliance":
|
||||||
|
if faction1 in (treaty.faction1, treaty.faction2) and faction2 in (treaty.faction1, treaty.faction2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
treaty = Treaty(
|
||||||
|
faction1=faction1,
|
||||||
|
faction2=faction2,
|
||||||
|
treaty_type="alliance",
|
||||||
|
start_turn=turn,
|
||||||
|
duration=0, # Permanent until broken
|
||||||
|
)
|
||||||
|
self.treaties.append(treaty)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_turn(self, current_turn: int) -> None:
|
||||||
|
"""Update diplomacy state each turn."""
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Remove expired treaties
|
||||||
|
self.treaties = [t for t in self.treaties if t.is_active(current_turn)]
|
||||||
|
|
||||||
|
# Relations naturally decay over time (things get worse without diplomacy)
|
||||||
|
# This makes active diplomacy necessary to maintain peace
|
||||||
|
for faction1 in FactionType:
|
||||||
|
for faction2 in FactionType:
|
||||||
|
if faction1 != faction2 and faction1 != FactionType.NEUTRAL and faction2 != FactionType.NEUTRAL:
|
||||||
|
current = self.get_relation(faction1, faction2)
|
||||||
|
# Relations decay down towards hostility
|
||||||
|
# Only decay if above minimum (0) to avoid negative values
|
||||||
|
if current > 0:
|
||||||
|
self.relations[faction1][faction2] = max(0, current - config.diplomacy.relation_decay)
|
||||||
|
|
||||||
|
# Increase war exhaustion for factions at war
|
||||||
|
for war_pair in self.active_wars:
|
||||||
|
faction1_name, faction2_name = war_pair
|
||||||
|
for faction in FactionType:
|
||||||
|
if faction.value in (faction1_name, faction2_name):
|
||||||
|
self.war_exhaustion[faction] = self.war_exhaustion.get(faction, 0) + config.diplomacy.war_exhaustion_rate
|
||||||
|
|
||||||
|
def is_at_war(self, faction1: FactionType, faction2: FactionType) -> bool:
|
||||||
|
"""Check if two factions are at war."""
|
||||||
|
if faction1 == faction2:
|
||||||
|
return False
|
||||||
|
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||||
|
return war_pair in self.active_wars
|
||||||
|
|
||||||
|
def is_allied(self, faction1: FactionType, faction2: FactionType) -> bool:
|
||||||
|
"""Check if two factions are allied."""
|
||||||
|
if faction1 == faction2:
|
||||||
|
return True
|
||||||
|
for treaty in self.treaties:
|
||||||
|
if treaty.treaty_type == "alliance":
|
||||||
|
if faction1 in (treaty.faction1, treaty.faction2) and faction2 in (treaty.faction1, treaty.faction2):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_trade_modifier(self, faction1: FactionType, faction2: FactionType) -> float:
|
||||||
|
"""Get trade modifier between factions based on relations."""
|
||||||
|
if faction1 == faction2:
|
||||||
|
return 1.2 # Same faction bonus
|
||||||
|
|
||||||
|
status = self.get_status(faction1, faction2)
|
||||||
|
|
||||||
|
modifiers = {
|
||||||
|
DiplomaticStatus.WAR: 0.0, # No trade during war
|
||||||
|
DiplomaticStatus.HOSTILE: 0.5,
|
||||||
|
DiplomaticStatus.COLD: 0.8,
|
||||||
|
DiplomaticStatus.NEUTRAL: 1.0,
|
||||||
|
DiplomaticStatus.FRIENDLY: 1.1,
|
||||||
|
DiplomaticStatus.ALLIED: 1.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifiers.get(status, 1.0)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"relations": {
|
||||||
|
f1.value: {f2.value: v for f2, v in inner.items()}
|
||||||
|
for f1, inner in self.relations.items()
|
||||||
|
},
|
||||||
|
"active_wars": list(self.active_wars),
|
||||||
|
"treaties": [t.to_dict() for t in self.treaties],
|
||||||
|
"war_exhaustion": {f.value: e for f, e in self.war_exhaustion.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentDiplomacy:
|
||||||
|
"""An agent's diplomatic state and faction membership."""
|
||||||
|
faction: FactionType = FactionType.NEUTRAL
|
||||||
|
|
||||||
|
# Personal relations with other agents (agent_id -> relation value)
|
||||||
|
personal_relations: Dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Diplomatic actions taken
|
||||||
|
negotiations_conducted: int = 0
|
||||||
|
wars_declared: int = 0
|
||||||
|
peace_treaties_made: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def traits(self) -> dict:
|
||||||
|
"""Get faction traits."""
|
||||||
|
return FACTION_TRAITS.get(self.faction, FACTION_TRAITS[FactionType.NEUTRAL])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def diplomacy_skill(self) -> float:
|
||||||
|
"""Get base diplomacy skill from faction."""
|
||||||
|
return self.traits.get("diplomacy_skill", 0.5)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aggression(self) -> float:
|
||||||
|
"""Get faction aggression level."""
|
||||||
|
return self.traits.get("aggression", 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trade_preference(self) -> float:
|
||||||
|
"""Get faction trade preference."""
|
||||||
|
return self.traits.get("trade_preference", 1.0)
|
||||||
|
|
||||||
|
def get_personal_relation(self, other_id: str) -> int:
|
||||||
|
"""Get personal relation with another agent."""
|
||||||
|
return self.personal_relations.get(other_id, 50)
|
||||||
|
|
||||||
|
def modify_personal_relation(self, other_id: str, amount: int) -> int:
|
||||||
|
"""Modify personal relation with another agent."""
|
||||||
|
current = self.personal_relations.get(other_id, 50)
|
||||||
|
new_value = max(0, min(100, current + amount))
|
||||||
|
self.personal_relations[other_id] = new_value
|
||||||
|
return new_value
|
||||||
|
|
||||||
|
def should_negotiate(self, other: "AgentDiplomacy", faction_relations: FactionRelations) -> bool:
|
||||||
|
"""Check if agent should try to negotiate with another."""
|
||||||
|
if self.faction == FactionType.NEUTRAL:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if at war - high motivation to negotiate peace if exhausted
|
||||||
|
if faction_relations.is_at_war(self.faction, other.faction):
|
||||||
|
exhaustion = faction_relations.war_exhaustion.get(self.faction, 0)
|
||||||
|
return exhaustion > 20 and random.random() < self.diplomacy_skill
|
||||||
|
|
||||||
|
# Try to improve relations if not allied
|
||||||
|
if not faction_relations.is_allied(self.faction, other.faction):
|
||||||
|
return random.random() < self.diplomacy_skill * 0.3
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def should_declare_war(self, other: "AgentDiplomacy", faction_relations: FactionRelations) -> bool:
|
||||||
|
"""Check if agent should try to declare war."""
|
||||||
|
if self.faction == FactionType.NEUTRAL or other.faction == FactionType.NEUTRAL:
|
||||||
|
return False
|
||||||
|
if self.faction == other.faction:
|
||||||
|
return False
|
||||||
|
if faction_relations.is_at_war(self.faction, other.faction):
|
||||||
|
return False # Already at war
|
||||||
|
|
||||||
|
relation = faction_relations.get_relation(self.faction, other.faction)
|
||||||
|
|
||||||
|
# War is more likely with low relations and high aggression
|
||||||
|
war_probability = (self.aggression * (1 - relation / 100)) * 0.2
|
||||||
|
|
||||||
|
return random.random() < war_probability
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"faction": self.faction.value,
|
||||||
|
"faction_description": self.traits.get("description", ""),
|
||||||
|
"faction_color": self.traits.get("color", "#808080"),
|
||||||
|
"diplomacy_skill": self.diplomacy_skill,
|
||||||
|
"aggression": self.aggression,
|
||||||
|
"negotiations_conducted": self.negotiations_conducted,
|
||||||
|
"wars_declared": self.wars_declared,
|
||||||
|
"peace_treaties_made": self.peace_treaties_made,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_random_faction(archetype: Optional[str] = None) -> AgentDiplomacy:
|
||||||
|
"""Generate random faction membership for an agent."""
|
||||||
|
factions = list(FactionType)
|
||||||
|
weights = [1.0] * len(factions)
|
||||||
|
|
||||||
|
# Lower weight for neutral
|
||||||
|
weights[factions.index(FactionType.NEUTRAL)] = 0.3
|
||||||
|
|
||||||
|
# Archetype influences faction choice
|
||||||
|
if archetype == "hunter":
|
||||||
|
weights[factions.index(FactionType.FORESTKIN)] = 3.0
|
||||||
|
weights[factions.index(FactionType.MOUNTAINEER)] = 2.0
|
||||||
|
elif archetype == "gatherer":
|
||||||
|
weights[factions.index(FactionType.PLAINSMEN)] = 3.0
|
||||||
|
weights[factions.index(FactionType.RIVERFOLK)] = 2.0
|
||||||
|
elif archetype == "trader":
|
||||||
|
weights[factions.index(FactionType.RIVERFOLK)] = 3.0
|
||||||
|
elif archetype == "woodcutter":
|
||||||
|
weights[factions.index(FactionType.NORTHLANDS)] = 3.0
|
||||||
|
weights[factions.index(FactionType.FORESTKIN)] = 2.0
|
||||||
|
|
||||||
|
# Weighted random selection
|
||||||
|
total = sum(weights)
|
||||||
|
r = random.random() * total
|
||||||
|
cumulative = 0
|
||||||
|
chosen_faction = FactionType.NEUTRAL
|
||||||
|
|
||||||
|
for faction, weight in zip(factions, weights):
|
||||||
|
cumulative += weight
|
||||||
|
if r <= cumulative:
|
||||||
|
chosen_faction = faction
|
||||||
|
break
|
||||||
|
|
||||||
|
return AgentDiplomacy(faction=chosen_faction)
|
||||||
|
|
||||||
|
|
||||||
|
# Global faction relations (shared across all agents)
|
||||||
|
_global_faction_relations: Optional[FactionRelations] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_faction_relations() -> FactionRelations:
|
||||||
|
"""Get the global faction relations instance."""
|
||||||
|
global _global_faction_relations
|
||||||
|
if _global_faction_relations is None:
|
||||||
|
_global_faction_relations = FactionRelations()
|
||||||
|
return _global_faction_relations
|
||||||
|
|
||||||
|
|
||||||
|
def reset_faction_relations() -> FactionRelations:
|
||||||
|
"""Reset faction relations to default state."""
|
||||||
|
global _global_faction_relations
|
||||||
|
_global_faction_relations = FactionRelations()
|
||||||
|
return _global_faction_relations
|
||||||
|
|
||||||
337
backend/domain/religion.py
Normal file
337
backend/domain/religion.py
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
"""Religion system for the Village Simulation.
|
||||||
|
|
||||||
|
Creates diverse religious beliefs that affect agent behavior:
|
||||||
|
- Each agent has a religion (or atheist)
|
||||||
|
- Faith level affects actions and decisions
|
||||||
|
- Same-religion agents cooperate better
|
||||||
|
- Different religions can create conflict
|
||||||
|
- High faith agents become zealots
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ReligionType(Enum):
|
||||||
|
"""Types of religions in the simulation.
|
||||||
|
|
||||||
|
These represent different belief systems with unique characteristics.
|
||||||
|
"""
|
||||||
|
ATHEIST = "atheist" # No religion - neutral
|
||||||
|
SOLARIS = "solaris" # Sun worshippers - value energy and activity
|
||||||
|
AQUARIUS = "aquarius" # Water worshippers - value water and peace
|
||||||
|
TERRANUS = "terranus" # Earth worshippers - value resources and hoarding
|
||||||
|
IGNIS = "ignis" # Fire worshippers - value heat and trade
|
||||||
|
NATURIS = "naturis" # Nature worshippers - value gathering and sustainability
|
||||||
|
|
||||||
|
|
||||||
|
# Religion characteristics
|
||||||
|
RELIGION_TRAITS = {
|
||||||
|
ReligionType.ATHEIST: {
|
||||||
|
"description": "No religious belief",
|
||||||
|
"bonus_action": None,
|
||||||
|
"preferred_resource": None,
|
||||||
|
"aggression": 0.0,
|
||||||
|
"conversion_resistance": 0.3,
|
||||||
|
},
|
||||||
|
ReligionType.SOLARIS: {
|
||||||
|
"description": "Worshippers of the Sun",
|
||||||
|
"bonus_action": "hunt", # Sun gives strength to hunt
|
||||||
|
"preferred_resource": "meat",
|
||||||
|
"aggression": 0.4, # Moderate aggression
|
||||||
|
"conversion_resistance": 0.6,
|
||||||
|
},
|
||||||
|
ReligionType.AQUARIUS: {
|
||||||
|
"description": "Worshippers of Water",
|
||||||
|
"bonus_action": "get_water",
|
||||||
|
"preferred_resource": "water",
|
||||||
|
"aggression": 0.1, # Peaceful religion
|
||||||
|
"conversion_resistance": 0.7,
|
||||||
|
},
|
||||||
|
ReligionType.TERRANUS: {
|
||||||
|
"description": "Worshippers of the Earth",
|
||||||
|
"bonus_action": "gather",
|
||||||
|
"preferred_resource": "berries",
|
||||||
|
"aggression": 0.2,
|
||||||
|
"conversion_resistance": 0.8,
|
||||||
|
},
|
||||||
|
ReligionType.IGNIS: {
|
||||||
|
"description": "Worshippers of Fire",
|
||||||
|
"bonus_action": "trade", # Fire of commerce
|
||||||
|
"preferred_resource": "wood",
|
||||||
|
"aggression": 0.5, # Hot-tempered
|
||||||
|
"conversion_resistance": 0.5,
|
||||||
|
},
|
||||||
|
ReligionType.NATURIS: {
|
||||||
|
"description": "Worshippers of Nature",
|
||||||
|
"bonus_action": "gather",
|
||||||
|
"preferred_resource": "berries",
|
||||||
|
"aggression": 0.15, # Peaceful
|
||||||
|
"conversion_resistance": 0.75,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReligiousBeliefs:
|
||||||
|
"""An agent's religious beliefs and faith state."""
|
||||||
|
religion: ReligionType = ReligionType.ATHEIST
|
||||||
|
faith: int = 50 # 0-100, loaded from config
|
||||||
|
|
||||||
|
# Historical conversion tracking
|
||||||
|
times_converted: int = 0
|
||||||
|
last_prayer_turn: int = -1
|
||||||
|
|
||||||
|
# Zealot state
|
||||||
|
is_zealot: bool = False
|
||||||
|
|
||||||
|
# Religious influence
|
||||||
|
converts_made: int = 0
|
||||||
|
sermons_given: int = 0
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self._update_zealot_status()
|
||||||
|
|
||||||
|
def _update_zealot_status(self) -> None:
|
||||||
|
"""Update zealot status based on faith level."""
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
threshold = int(config.religion.zealot_threshold * 100)
|
||||||
|
self.is_zealot = self.faith >= threshold
|
||||||
|
|
||||||
|
@property
|
||||||
|
def traits(self) -> dict:
|
||||||
|
"""Get traits for current religion."""
|
||||||
|
return RELIGION_TRAITS.get(self.religion, RELIGION_TRAITS[ReligionType.ATHEIST])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
"""Get religion description."""
|
||||||
|
return self.traits["description"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_religious(self) -> bool:
|
||||||
|
"""Check if agent has a religion."""
|
||||||
|
return self.religion != ReligionType.ATHEIST
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conversion_resistance(self) -> float:
|
||||||
|
"""Get resistance to conversion."""
|
||||||
|
base = self.traits.get("conversion_resistance", 0.5)
|
||||||
|
# Higher faith = harder to convert
|
||||||
|
faith_modifier = self.faith / 100 * 0.3
|
||||||
|
return min(0.95, base + faith_modifier)
|
||||||
|
|
||||||
|
def gain_faith(self, amount: int) -> None:
|
||||||
|
"""Increase faith level."""
|
||||||
|
self.faith = min(100, self.faith + amount)
|
||||||
|
self._update_zealot_status()
|
||||||
|
|
||||||
|
def lose_faith(self, amount: int) -> None:
|
||||||
|
"""Decrease faith level."""
|
||||||
|
self.faith = max(0, self.faith - amount)
|
||||||
|
self._update_zealot_status()
|
||||||
|
|
||||||
|
def apply_decay(self) -> None:
|
||||||
|
"""Apply faith decay per turn (if not recently prayed)."""
|
||||||
|
from backend.config import get_config
|
||||||
|
decay = get_config().agent_stats.faith_decay
|
||||||
|
self.faith = max(0, self.faith - decay)
|
||||||
|
self._update_zealot_status()
|
||||||
|
|
||||||
|
def convert_to(self, new_religion: ReligionType, faith_level: int = 30) -> bool:
|
||||||
|
"""Attempt to convert to a new religion."""
|
||||||
|
if new_religion == self.religion:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check conversion resistance
|
||||||
|
if random.random() < self.conversion_resistance:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.religion = new_religion
|
||||||
|
self.faith = faith_level
|
||||||
|
self.times_converted += 1
|
||||||
|
self._update_zealot_status()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def record_prayer(self, turn: int) -> None:
|
||||||
|
"""Record that prayer was performed."""
|
||||||
|
self.last_prayer_turn = turn
|
||||||
|
|
||||||
|
def record_conversion(self) -> None:
|
||||||
|
"""Record a successful conversion made."""
|
||||||
|
self.converts_made += 1
|
||||||
|
|
||||||
|
def record_sermon(self) -> None:
|
||||||
|
"""Record a sermon given."""
|
||||||
|
self.sermons_given += 1
|
||||||
|
|
||||||
|
def get_trade_modifier(self, other: "ReligiousBeliefs") -> float:
|
||||||
|
"""Get trade modifier when dealing with another agent's religion."""
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if self.religion == ReligionType.ATHEIST or other.religion == ReligionType.ATHEIST:
|
||||||
|
return 1.0 # No modifier for atheists
|
||||||
|
|
||||||
|
if self.religion == other.religion:
|
||||||
|
# Same religion bonus
|
||||||
|
bonus = config.religion.same_religion_bonus
|
||||||
|
# Zealots get extra bonus
|
||||||
|
if self.is_zealot and other.is_zealot:
|
||||||
|
bonus *= 1.5
|
||||||
|
return 1.0 + bonus
|
||||||
|
else:
|
||||||
|
# Different religion penalty
|
||||||
|
penalty = config.religion.different_religion_penalty
|
||||||
|
# Zealots are more hostile to other religions
|
||||||
|
if self.is_zealot:
|
||||||
|
penalty *= 1.5
|
||||||
|
return 1.0 - penalty
|
||||||
|
|
||||||
|
def should_convert_other(self, other: "ReligiousBeliefs") -> bool:
|
||||||
|
"""Check if agent should try to convert another agent."""
|
||||||
|
if not self.is_religious:
|
||||||
|
return False
|
||||||
|
if self.religion == other.religion:
|
||||||
|
return False
|
||||||
|
# Zealots always want to convert
|
||||||
|
if self.is_zealot:
|
||||||
|
return True
|
||||||
|
# High faith agents sometimes want to convert
|
||||||
|
return random.random() < (self.faith / 100) * 0.5
|
||||||
|
|
||||||
|
def is_hostile_to(self, other: "ReligiousBeliefs") -> bool:
|
||||||
|
"""Check if religiously hostile to another agent."""
|
||||||
|
if not self.is_religious or not other.is_religious:
|
||||||
|
return False
|
||||||
|
if self.religion == other.religion:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Only zealots are hostile
|
||||||
|
if not self.is_zealot:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if faith is high enough for holy war
|
||||||
|
if self.faith >= config.religion.holy_war_threshold * 100:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return random.random() < self.traits.get("aggression", 0.0)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"religion": self.religion.value,
|
||||||
|
"faith": self.faith,
|
||||||
|
"is_zealot": self.is_zealot,
|
||||||
|
"times_converted": self.times_converted,
|
||||||
|
"converts_made": self.converts_made,
|
||||||
|
"description": self.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_random_religion(archetype: Optional[str] = None) -> ReligiousBeliefs:
|
||||||
|
"""Generate random religious beliefs for an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
archetype: Optional personality archetype that influences religion
|
||||||
|
"""
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Get available religions
|
||||||
|
religions = list(ReligionType)
|
||||||
|
|
||||||
|
# Weight by archetype
|
||||||
|
weights = [1.0] * len(religions)
|
||||||
|
|
||||||
|
if archetype == "hunter":
|
||||||
|
# Hunters favor Solaris (sun/strength)
|
||||||
|
weights[religions.index(ReligionType.SOLARIS)] = 3.0
|
||||||
|
weights[religions.index(ReligionType.IGNIS)] = 2.0
|
||||||
|
elif archetype == "gatherer":
|
||||||
|
# Gatherers favor Naturis/Terranus
|
||||||
|
weights[religions.index(ReligionType.NATURIS)] = 3.0
|
||||||
|
weights[religions.index(ReligionType.TERRANUS)] = 2.0
|
||||||
|
elif archetype == "trader":
|
||||||
|
# Traders favor Ignis (commerce)
|
||||||
|
weights[religions.index(ReligionType.IGNIS)] = 3.0
|
||||||
|
weights[religions.index(ReligionType.AQUARIUS)] = 2.0 # Water trade routes
|
||||||
|
elif archetype == "woodcutter":
|
||||||
|
weights[religions.index(ReligionType.TERRANUS)] = 2.0
|
||||||
|
weights[religions.index(ReligionType.NATURIS)] = 1.5
|
||||||
|
|
||||||
|
# Atheists are uncommon - lower base weight
|
||||||
|
weights[religions.index(ReligionType.ATHEIST)] = 0.2
|
||||||
|
|
||||||
|
# Weighted random selection
|
||||||
|
total = sum(weights)
|
||||||
|
r = random.random() * total
|
||||||
|
cumulative = 0
|
||||||
|
chosen_religion = ReligionType.ATHEIST
|
||||||
|
|
||||||
|
for i, (religion, weight) in enumerate(zip(religions, weights)):
|
||||||
|
cumulative += weight
|
||||||
|
if r <= cumulative:
|
||||||
|
chosen_religion = religion
|
||||||
|
break
|
||||||
|
|
||||||
|
# Starting faith varies
|
||||||
|
if chosen_religion == ReligionType.ATHEIST:
|
||||||
|
starting_faith = random.randint(0, 20)
|
||||||
|
else:
|
||||||
|
starting_faith = random.randint(30, 70)
|
||||||
|
|
||||||
|
return ReligiousBeliefs(
|
||||||
|
religion=chosen_religion,
|
||||||
|
faith=starting_faith,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_religion_compatibility(religion1: ReligionType, religion2: ReligionType) -> float:
|
||||||
|
"""Get compatibility score between two religions (0-1)."""
|
||||||
|
if religion1 == religion2:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
if religion1 == ReligionType.ATHEIST or religion2 == ReligionType.ATHEIST:
|
||||||
|
return 0.7 # Atheists are neutral
|
||||||
|
|
||||||
|
# Compatible pairs
|
||||||
|
compatible_pairs = [
|
||||||
|
(ReligionType.NATURIS, ReligionType.AQUARIUS), # Nature and water
|
||||||
|
(ReligionType.TERRANUS, ReligionType.NATURIS), # Earth and nature
|
||||||
|
(ReligionType.SOLARIS, ReligionType.IGNIS), # Sun and fire
|
||||||
|
]
|
||||||
|
|
||||||
|
# Hostile pairs
|
||||||
|
hostile_pairs = [
|
||||||
|
(ReligionType.AQUARIUS, ReligionType.IGNIS), # Water vs fire
|
||||||
|
(ReligionType.SOLARIS, ReligionType.AQUARIUS), # Sun vs water
|
||||||
|
]
|
||||||
|
|
||||||
|
pair = (religion1, religion2)
|
||||||
|
reverse_pair = (religion2, religion1)
|
||||||
|
|
||||||
|
if pair in compatible_pairs or reverse_pair in compatible_pairs:
|
||||||
|
return 0.8
|
||||||
|
if pair in hostile_pairs or reverse_pair in hostile_pairs:
|
||||||
|
return 0.3
|
||||||
|
|
||||||
|
return 0.5 # Neutral
|
||||||
|
|
||||||
|
|
||||||
|
def get_religion_action_bonus(religion: ReligionType, action_type: str) -> float:
|
||||||
|
"""Get action bonus/penalty for a religion performing an action."""
|
||||||
|
traits = RELIGION_TRAITS.get(religion, {})
|
||||||
|
bonus_action = traits.get("bonus_action")
|
||||||
|
|
||||||
|
if bonus_action == action_type:
|
||||||
|
return 1.15 # 15% bonus for favored action
|
||||||
|
|
||||||
|
return 1.0
|
||||||
|
|
||||||
@ -19,6 +19,9 @@ class ResourceType(Enum):
|
|||||||
WOOD = "wood"
|
WOOD = "wood"
|
||||||
HIDE = "hide"
|
HIDE = "hide"
|
||||||
CLOTHES = "clothes"
|
CLOTHES = "clothes"
|
||||||
|
# NEW: Oil industry resources
|
||||||
|
OIL = "oil" # Raw crude oil - must be refined
|
||||||
|
FUEL = "fuel" # Refined fuel - provides heat and energy
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -32,7 +35,6 @@ class ResourceEffect:
|
|||||||
|
|
||||||
def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
||||||
"""Get resource effects from the global config."""
|
"""Get resource effects from the global config."""
|
||||||
# Import here to avoid circular imports
|
|
||||||
from backend.config import get_config
|
from backend.config import get_config
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
@ -53,12 +55,19 @@ def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
|||||||
ResourceType.WOOD: ResourceEffect(), # Used as fuel, not consumed directly
|
ResourceType.WOOD: ResourceEffect(), # Used as fuel, not consumed directly
|
||||||
ResourceType.HIDE: ResourceEffect(), # Used for crafting
|
ResourceType.HIDE: ResourceEffect(), # Used for crafting
|
||||||
ResourceType.CLOTHES: ResourceEffect(), # Passive heat reduction effect
|
ResourceType.CLOTHES: ResourceEffect(), # Passive heat reduction effect
|
||||||
|
# NEW: Oil resources
|
||||||
|
ResourceType.OIL: ResourceEffect(
|
||||||
|
energy=resources.oil_energy, # Raw oil has no direct use
|
||||||
|
),
|
||||||
|
ResourceType.FUEL: ResourceEffect(
|
||||||
|
energy=resources.fuel_energy, # Refined fuel provides energy
|
||||||
|
heat=resources.fuel_heat, # And significant heat
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
||||||
"""Get resource decay rates from the global config."""
|
"""Get resource decay rates from the global config."""
|
||||||
# Import here to avoid circular imports
|
|
||||||
from backend.config import get_config
|
from backend.config import get_config
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
@ -71,6 +80,9 @@ def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
|||||||
ResourceType.WOOD: None, # Infinite
|
ResourceType.WOOD: None, # Infinite
|
||||||
ResourceType.HIDE: None, # Infinite
|
ResourceType.HIDE: None, # Infinite
|
||||||
ResourceType.CLOTHES: resources.clothes_decay if resources.clothes_decay > 0 else None,
|
ResourceType.CLOTHES: resources.clothes_decay if resources.clothes_decay > 0 else None,
|
||||||
|
# NEW: Oil resources don't decay
|
||||||
|
ResourceType.OIL: resources.oil_decay if resources.oil_decay > 0 else None,
|
||||||
|
ResourceType.FUEL: resources.fuel_decay if resources.fuel_decay > 0 else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -80,6 +92,12 @@ def get_fire_heat() -> int:
|
|||||||
return get_config().resources.fire_heat
|
return get_config().resources.fire_heat
|
||||||
|
|
||||||
|
|
||||||
|
def get_fuel_heat() -> int:
|
||||||
|
"""Get heat provided by burning fuel."""
|
||||||
|
from backend.config import get_config
|
||||||
|
return get_config().resources.fuel_heat
|
||||||
|
|
||||||
|
|
||||||
# Cached values for performance
|
# Cached values for performance
|
||||||
_resource_effects_cache: Optional[dict[ResourceType, ResourceEffect]] = None
|
_resource_effects_cache: Optional[dict[ResourceType, ResourceEffect]] = None
|
||||||
_resource_decay_cache: Optional[dict[ResourceType, Optional[int]]] = None
|
_resource_decay_cache: Optional[dict[ResourceType, Optional[int]]] = None
|
||||||
@ -140,6 +158,37 @@ RESOURCE_EFFECTS = _ResourceEffectsAccessor()
|
|||||||
RESOURCE_DECAY_RATES = _ResourceDecayAccessor()
|
RESOURCE_DECAY_RATES = _ResourceDecayAccessor()
|
||||||
|
|
||||||
|
|
||||||
|
# Resource categories for AI and display
|
||||||
|
FOOD_RESOURCES = {ResourceType.MEAT, ResourceType.BERRIES}
|
||||||
|
DRINK_RESOURCES = {ResourceType.WATER}
|
||||||
|
HEAT_RESOURCES = {ResourceType.WOOD, ResourceType.FUEL}
|
||||||
|
CRAFTING_MATERIALS = {ResourceType.HIDE, ResourceType.OIL}
|
||||||
|
VALUABLE_RESOURCES = {ResourceType.OIL, ResourceType.FUEL, ResourceType.CLOTHES}
|
||||||
|
|
||||||
|
|
||||||
|
def get_resource_base_value(resource_type: ResourceType) -> int:
|
||||||
|
"""Get the base economic value of a resource."""
|
||||||
|
from backend.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Oil and fuel have special pricing
|
||||||
|
if resource_type == ResourceType.OIL:
|
||||||
|
return config.economy.oil_base_price
|
||||||
|
elif resource_type == ResourceType.FUEL:
|
||||||
|
return config.economy.fuel_base_price
|
||||||
|
|
||||||
|
# Other resources based on production cost
|
||||||
|
base_values = {
|
||||||
|
ResourceType.MEAT: 15,
|
||||||
|
ResourceType.BERRIES: 5,
|
||||||
|
ResourceType.WATER: 3,
|
||||||
|
ResourceType.WOOD: 8,
|
||||||
|
ResourceType.HIDE: 10,
|
||||||
|
ResourceType.CLOTHES: 20,
|
||||||
|
}
|
||||||
|
return base_values.get(resource_type, 10)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Resource:
|
class Resource:
|
||||||
"""A resource instance in the simulation."""
|
"""A resource instance in the simulation."""
|
||||||
@ -157,6 +206,16 @@ class Resource:
|
|||||||
"""Get the effect of consuming this resource."""
|
"""Get the effect of consuming this resource."""
|
||||||
return get_cached_resource_effects()[self.type]
|
return get_cached_resource_effects()[self.type]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valuable(self) -> bool:
|
||||||
|
"""Check if this is a high-value resource."""
|
||||||
|
return self.type in VALUABLE_RESOURCES
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_value(self) -> int:
|
||||||
|
"""Get the base economic value."""
|
||||||
|
return get_resource_base_value(self.type)
|
||||||
|
|
||||||
def is_expired(self, current_turn: int) -> bool:
|
def is_expired(self, current_turn: int) -> bool:
|
||||||
"""Check if the resource has decayed."""
|
"""Check if the resource has decayed."""
|
||||||
if self.decay_rate is None:
|
if self.decay_rate is None:
|
||||||
@ -177,4 +236,5 @@ class Resource:
|
|||||||
"quantity": self.quantity,
|
"quantity": self.quantity,
|
||||||
"created_turn": self.created_turn,
|
"created_turn": self.created_turn,
|
||||||
"decay_rate": self.decay_rate,
|
"decay_rate": self.decay_rate,
|
||||||
|
"base_value": self.base_value,
|
||||||
}
|
}
|
||||||
|
|||||||
135
config.json
135
config.json
@ -1,73 +1,118 @@
|
|||||||
{
|
{
|
||||||
"agent_stats": {
|
"agent_stats": {
|
||||||
"max_energy": 50,
|
"max_energy": 60,
|
||||||
"max_hunger": 100,
|
"max_hunger": 100,
|
||||||
"max_thirst": 100,
|
"max_thirst": 100,
|
||||||
"max_heat": 100,
|
"max_heat": 100,
|
||||||
"start_energy": 50,
|
"max_faith": 100,
|
||||||
"start_hunger": 70,
|
"start_energy": 60,
|
||||||
"start_thirst": 75,
|
"start_hunger": 90,
|
||||||
|
"start_thirst": 90,
|
||||||
"start_heat": 100,
|
"start_heat": 100,
|
||||||
|
"start_faith": 45,
|
||||||
"energy_decay": 1,
|
"energy_decay": 1,
|
||||||
"hunger_decay": 2,
|
"hunger_decay": 1,
|
||||||
"thirst_decay": 3,
|
"thirst_decay": 2,
|
||||||
"heat_decay": 3,
|
"heat_decay": 2,
|
||||||
"critical_threshold": 0.25,
|
"faith_decay": 1,
|
||||||
|
"critical_threshold": 0.18,
|
||||||
"low_energy_threshold": 12
|
"low_energy_threshold": 12
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"meat_decay": 10,
|
"meat_decay": 15,
|
||||||
"berries_decay": 6,
|
"berries_decay": 10,
|
||||||
"clothes_decay": 20,
|
"clothes_decay": 30,
|
||||||
"meat_hunger": 35,
|
"oil_decay": 0,
|
||||||
"meat_energy": 12,
|
"fuel_decay": 0,
|
||||||
"berries_hunger": 10,
|
"meat_hunger": 45,
|
||||||
"berries_thirst": 4,
|
"meat_energy": 15,
|
||||||
"water_thirst": 50,
|
"berries_hunger": 15,
|
||||||
"fire_heat": 20
|
"berries_thirst": 6,
|
||||||
|
"water_thirst": 60,
|
||||||
|
"fire_heat": 30,
|
||||||
|
"fuel_heat": 45,
|
||||||
|
"oil_energy": 0,
|
||||||
|
"fuel_energy": 12
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"sleep_energy": 55,
|
"sleep_energy": 55,
|
||||||
"rest_energy": 12,
|
"rest_energy": 15,
|
||||||
"hunt_energy": -7,
|
"hunt_energy": -5,
|
||||||
"gather_energy": -3,
|
"gather_energy": -2,
|
||||||
"chop_wood_energy": -6,
|
"chop_wood_energy": -4,
|
||||||
"get_water_energy": -2,
|
"get_water_energy": -2,
|
||||||
"weave_energy": -6,
|
"weave_energy": -4,
|
||||||
"build_fire_energy": -4,
|
"build_fire_energy": -3,
|
||||||
"trade_energy": -1,
|
"trade_energy": -1,
|
||||||
"hunt_success": 0.70,
|
"drill_oil_energy": -7,
|
||||||
|
"refine_energy": -5,
|
||||||
|
"pray_energy": -2,
|
||||||
|
"preach_energy": -3,
|
||||||
|
"negotiate_energy": -2,
|
||||||
|
"declare_war_energy": -3,
|
||||||
|
"make_peace_energy": -2,
|
||||||
|
"hunt_success": 0.80,
|
||||||
"chop_wood_success": 0.90,
|
"chop_wood_success": 0.90,
|
||||||
"hunt_meat_min": 2,
|
"drill_oil_success": 0.70,
|
||||||
"hunt_meat_max": 5,
|
"hunt_meat_min": 3,
|
||||||
|
"hunt_meat_max": 6,
|
||||||
"hunt_hide_min": 0,
|
"hunt_hide_min": 0,
|
||||||
"hunt_hide_max": 2,
|
"hunt_hide_max": 2,
|
||||||
"gather_min": 2,
|
"gather_min": 3,
|
||||||
"gather_max": 4,
|
"gather_max": 6,
|
||||||
"chop_wood_min": 1,
|
"chop_wood_min": 2,
|
||||||
"chop_wood_max": 3
|
"chop_wood_max": 4,
|
||||||
|
"drill_oil_min": 2,
|
||||||
|
"drill_oil_max": 5,
|
||||||
|
"pray_faith_gain": 18,
|
||||||
|
"preach_faith_spread": 10,
|
||||||
|
"preach_convert_chance": 0.10
|
||||||
},
|
},
|
||||||
"world": {
|
"world": {
|
||||||
"width": 25,
|
"width": 50,
|
||||||
"height": 25,
|
"height": 50,
|
||||||
"initial_agents": 25,
|
"initial_agents": 100,
|
||||||
"day_steps": 10,
|
"day_steps": 10,
|
||||||
"night_steps": 1,
|
"night_steps": 1,
|
||||||
"inventory_slots": 12,
|
"inventory_slots": 14,
|
||||||
"starting_money": 80
|
"starting_money": 100,
|
||||||
|
"oil_fields_count": 5,
|
||||||
|
"temple_count": 5
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"turns_before_discount": 15,
|
"turns_before_discount": 10,
|
||||||
"discount_rate": 0.12,
|
"discount_rate": 0.08,
|
||||||
"base_price_multiplier": 1.3
|
"base_price_multiplier": 1.15
|
||||||
},
|
},
|
||||||
"economy": {
|
"economy": {
|
||||||
"energy_to_money_ratio": 1.5,
|
"energy_to_money_ratio": 1.2,
|
||||||
"wealth_desire": 0.35,
|
"wealth_desire": 0.25,
|
||||||
"buy_efficiency_threshold": 0.75,
|
"buy_efficiency_threshold": 0.85,
|
||||||
"min_wealth_target": 50,
|
"min_wealth_target": 30,
|
||||||
"max_price_markup": 2.5,
|
"max_price_markup": 1.8,
|
||||||
"min_price_discount": 0.4
|
"min_price_discount": 0.5,
|
||||||
|
"oil_base_price": 18,
|
||||||
|
"fuel_base_price": 30
|
||||||
},
|
},
|
||||||
"auto_step_interval": 0.15
|
"religion": {
|
||||||
|
"num_religions": 5,
|
||||||
|
"conversion_resistance": 0.55,
|
||||||
|
"zealot_threshold": 0.75,
|
||||||
|
"faith_trade_bonus": 0.10,
|
||||||
|
"same_religion_bonus": 0.12,
|
||||||
|
"different_religion_penalty": 0.06,
|
||||||
|
"holy_war_threshold": 0.85
|
||||||
|
},
|
||||||
|
"diplomacy": {
|
||||||
|
"num_factions": 5,
|
||||||
|
"starting_relations": 50,
|
||||||
|
"alliance_threshold": 75,
|
||||||
|
"war_threshold": 20,
|
||||||
|
"relation_decay": 2,
|
||||||
|
"trade_relation_boost": 5,
|
||||||
|
"war_damage_multiplier": 1.2,
|
||||||
|
"peace_treaty_duration": 15,
|
||||||
|
"war_exhaustion_rate": 5
|
||||||
|
},
|
||||||
|
"auto_step_interval": 0.10
|
||||||
}
|
}
|
||||||
@ -1,7 +1,10 @@
|
|||||||
"""HTTP client for communicating with the Village Simulation backend."""
|
"""HTTP client for communicating with the Village Simulation backend.
|
||||||
|
|
||||||
|
Handles state including religion, factions, diplomacy, and oil economy.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@ -25,6 +28,15 @@ class SimulationState:
|
|||||||
is_running: bool
|
is_running: bool
|
||||||
recent_logs: list[dict]
|
recent_logs: list[dict]
|
||||||
|
|
||||||
|
# New fields for religion, factions, diplomacy
|
||||||
|
oil_fields: list[dict] = field(default_factory=list)
|
||||||
|
temples: list[dict] = field(default_factory=list)
|
||||||
|
faction_relations: dict = field(default_factory=dict)
|
||||||
|
diplomatic_events: list[dict] = field(default_factory=list)
|
||||||
|
religious_events: list[dict] = field(default_factory=list)
|
||||||
|
active_wars: list[dict] = field(default_factory=list)
|
||||||
|
peace_treaties: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_api_response(cls, data: dict) -> "SimulationState":
|
def from_api_response(cls, data: dict) -> "SimulationState":
|
||||||
"""Create from API response data."""
|
"""Create from API response data."""
|
||||||
@ -42,12 +54,75 @@ class SimulationState:
|
|||||||
mode=data.get("mode", "manual"),
|
mode=data.get("mode", "manual"),
|
||||||
is_running=data.get("is_running", False),
|
is_running=data.get("is_running", False),
|
||||||
recent_logs=data.get("recent_logs", []),
|
recent_logs=data.get("recent_logs", []),
|
||||||
|
# New fields
|
||||||
|
oil_fields=data.get("oil_fields", []),
|
||||||
|
temples=data.get("temples", []),
|
||||||
|
faction_relations=data.get("faction_relations", {}),
|
||||||
|
diplomatic_events=data.get("diplomatic_events", []),
|
||||||
|
religious_events=data.get("religious_events", []),
|
||||||
|
active_wars=data.get("active_wars", []),
|
||||||
|
peace_treaties=data.get("peace_treaties", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_living_agents(self) -> list[dict]:
|
def get_living_agents(self) -> list[dict]:
|
||||||
"""Get only living agents."""
|
"""Get only living agents."""
|
||||||
return [a for a in self.agents if a.get("is_alive", False)]
|
return [a for a in self.agents if a.get("is_alive", False)]
|
||||||
|
|
||||||
|
def get_agents_by_faction(self) -> dict[str, list[dict]]:
|
||||||
|
"""Group living agents by faction."""
|
||||||
|
result: dict[str, list[dict]] = {}
|
||||||
|
for agent in self.get_living_agents():
|
||||||
|
# Faction is under diplomacy.faction (not faction.type)
|
||||||
|
diplomacy = agent.get("diplomacy", {})
|
||||||
|
faction = diplomacy.get("faction", "neutral")
|
||||||
|
if faction not in result:
|
||||||
|
result[faction] = []
|
||||||
|
result[faction].append(agent)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_agents_by_religion(self) -> dict[str, list[dict]]:
|
||||||
|
"""Group living agents by religion."""
|
||||||
|
result: dict[str, list[dict]] = {}
|
||||||
|
for agent in self.get_living_agents():
|
||||||
|
# Religion type is under religion.religion (not religion.type)
|
||||||
|
religion_data = agent.get("religion", {})
|
||||||
|
religion = religion_data.get("religion", "atheist")
|
||||||
|
if religion not in result:
|
||||||
|
result[religion] = []
|
||||||
|
result[religion].append(agent)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_faction_stats(self) -> dict:
|
||||||
|
"""Get faction statistics."""
|
||||||
|
stats = self.statistics.get("factions", {})
|
||||||
|
if not stats:
|
||||||
|
# Compute from agents if not in statistics
|
||||||
|
by_faction = self.get_agents_by_faction()
|
||||||
|
stats = {f: len(agents) for f, agents in by_faction.items()}
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def get_religion_stats(self) -> dict:
|
||||||
|
"""Get religion statistics."""
|
||||||
|
stats = self.statistics.get("religions", {})
|
||||||
|
if not stats:
|
||||||
|
# Compute from agents if not in statistics
|
||||||
|
by_religion = self.get_agents_by_religion()
|
||||||
|
stats = {r: len(agents) for r, agents in by_religion.items()}
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def get_avg_faith(self) -> float:
|
||||||
|
"""Get average faith level."""
|
||||||
|
avg = self.statistics.get("avg_faith", 0)
|
||||||
|
if not avg:
|
||||||
|
agents = self.get_living_agents()
|
||||||
|
if agents:
|
||||||
|
# Faith is under religion.faith
|
||||||
|
total_faith = sum(
|
||||||
|
a.get("religion", {}).get("faith", 50) for a in agents
|
||||||
|
)
|
||||||
|
avg = total_faith / len(agents)
|
||||||
|
return avg
|
||||||
|
|
||||||
|
|
||||||
class SimulationClient:
|
class SimulationClient:
|
||||||
"""HTTP client for the Village Simulation backend."""
|
"""HTTP client for the Village Simulation backend."""
|
||||||
@ -82,7 +157,7 @@ class SimulationClient:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self._retry_count = 0
|
self._retry_count = 0
|
||||||
return response.json()
|
return response.json()
|
||||||
except RequestException as e:
|
except RequestException:
|
||||||
self._retry_count += 1
|
self._retry_count += 1
|
||||||
if self._retry_count >= self._max_retries:
|
if self._retry_count >= self._max_retries:
|
||||||
self.connected = False
|
self.connected = False
|
||||||
@ -107,7 +182,7 @@ class SimulationClient:
|
|||||||
if data:
|
if data:
|
||||||
self.last_state = SimulationState.from_api_response(data)
|
self.last_state = SimulationState.from_api_response(data)
|
||||||
return self.last_state
|
return self.last_state
|
||||||
return self.last_state # Return cached state if request failed
|
return self.last_state
|
||||||
|
|
||||||
def advance_turn(self) -> bool:
|
def advance_turn(self) -> bool:
|
||||||
"""Advance the simulation by one step."""
|
"""Advance the simulation by one step."""
|
||||||
@ -121,9 +196,9 @@ class SimulationClient:
|
|||||||
|
|
||||||
def initialize(
|
def initialize(
|
||||||
self,
|
self,
|
||||||
num_agents: int = 8,
|
num_agents: int = 100,
|
||||||
world_width: int = 20,
|
world_width: int = 30,
|
||||||
world_height: int = 20,
|
world_height: int = 30,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Initialize or reset the simulation."""
|
"""Initialize or reset the simulation."""
|
||||||
result = self._request("POST", "/control/initialize", json={
|
result = self._request("POST", "/control/initialize", json={
|
||||||
@ -177,4 +252,3 @@ class SimulationClient:
|
|||||||
"""Reset configuration to defaults."""
|
"""Reset configuration to defaults."""
|
||||||
result = self._request("POST", "/config/reset")
|
result = self._request("POST", "/config/reset")
|
||||||
return result is not None and result.get("success", False)
|
return result is not None and result.get("success", False)
|
||||||
|
|
||||||
|
|||||||
112
frontend/main.py
112
frontend/main.py
@ -1,4 +1,7 @@
|
|||||||
"""Main Pygame application for the Village Simulation frontend."""
|
"""Main Pygame application for the Village Simulation frontend.
|
||||||
|
|
||||||
|
Redesigned for fullscreen, 100+ agents, with religion, factions, and diplomacy.
|
||||||
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import pygame
|
import pygame
|
||||||
@ -12,14 +15,13 @@ from frontend.renderer.stats_renderer import StatsRenderer
|
|||||||
|
|
||||||
|
|
||||||
# Window configuration
|
# Window configuration
|
||||||
WINDOW_WIDTH = 1200
|
WINDOW_TITLE = "Village Simulation - Economy, Religion & Diplomacy"
|
||||||
WINDOW_HEIGHT = 800
|
FPS = 60
|
||||||
WINDOW_TITLE = "Village Economy Simulation"
|
|
||||||
FPS = 30
|
|
||||||
|
|
||||||
# Layout configuration
|
# Layout ratios (will scale with screen)
|
||||||
TOP_PANEL_HEIGHT = 50
|
TOP_PANEL_HEIGHT_RATIO = 0.06
|
||||||
RIGHT_PANEL_WIDTH = 200
|
RIGHT_PANEL_WIDTH_RATIO = 0.22
|
||||||
|
BOTTOM_PANEL_HEIGHT_RATIO = 0.08
|
||||||
|
|
||||||
|
|
||||||
class VillageSimulationApp:
|
class VillageSimulationApp:
|
||||||
@ -30,31 +32,60 @@ class VillageSimulationApp:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
pygame.font.init()
|
pygame.font.init()
|
||||||
|
|
||||||
# Create window
|
# Get display info for fullscreen
|
||||||
self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
|
display_info = pygame.display.Info()
|
||||||
|
self.screen_width = display_info.current_w
|
||||||
|
self.screen_height = display_info.current_h
|
||||||
|
|
||||||
|
# Create fullscreen window
|
||||||
|
self.screen = pygame.display.set_mode(
|
||||||
|
(self.screen_width, self.screen_height),
|
||||||
|
pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE
|
||||||
|
)
|
||||||
pygame.display.set_caption(WINDOW_TITLE)
|
pygame.display.set_caption(WINDOW_TITLE)
|
||||||
|
|
||||||
|
# Hide mouse cursor briefly on startup
|
||||||
|
pygame.mouse.set_visible(True)
|
||||||
|
|
||||||
# Clock for FPS control
|
# Clock for FPS control
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
|
|
||||||
# Fonts
|
# Calculate layout dimensions
|
||||||
self.font = pygame.font.Font(None, 24)
|
self.top_panel_height = int(self.screen_height * TOP_PANEL_HEIGHT_RATIO)
|
||||||
|
self.right_panel_width = int(self.screen_width * RIGHT_PANEL_WIDTH_RATIO)
|
||||||
|
self.bottom_panel_height = int(self.screen_height * BOTTOM_PANEL_HEIGHT_RATIO)
|
||||||
|
|
||||||
|
# Fonts - scale with screen
|
||||||
|
font_scale = min(self.screen_width / 1920, self.screen_height / 1080)
|
||||||
|
self.font_size_small = max(14, int(16 * font_scale))
|
||||||
|
self.font_size_medium = max(18, int(22 * font_scale))
|
||||||
|
self.font_size_large = max(24, int(28 * font_scale))
|
||||||
|
|
||||||
|
self.font = pygame.font.Font(None, self.font_size_medium)
|
||||||
|
self.font_small = pygame.font.Font(None, self.font_size_small)
|
||||||
|
self.font_large = pygame.font.Font(None, self.font_size_large)
|
||||||
|
|
||||||
# Network client
|
# Network client
|
||||||
self.client = SimulationClient(server_url)
|
self.client = SimulationClient(server_url)
|
||||||
|
|
||||||
# Calculate map area
|
# Calculate map area (left side, between top and bottom panels)
|
||||||
self.map_rect = pygame.Rect(
|
self.map_rect = pygame.Rect(
|
||||||
0,
|
0,
|
||||||
TOP_PANEL_HEIGHT,
|
self.top_panel_height,
|
||||||
WINDOW_WIDTH - RIGHT_PANEL_WIDTH,
|
self.screen_width - self.right_panel_width,
|
||||||
WINDOW_HEIGHT - TOP_PANEL_HEIGHT,
|
self.screen_height - self.top_panel_height - self.bottom_panel_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize renderers
|
# Initialize renderers with screen dimensions
|
||||||
self.map_renderer = MapRenderer(self.screen, self.map_rect)
|
self.map_renderer = MapRenderer(self.screen, self.map_rect)
|
||||||
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font)
|
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font_small)
|
||||||
self.ui_renderer = UIRenderer(self.screen, self.font)
|
self.ui_renderer = UIRenderer(
|
||||||
|
self.screen,
|
||||||
|
self.font,
|
||||||
|
self.top_panel_height,
|
||||||
|
self.right_panel_width,
|
||||||
|
self.bottom_panel_height
|
||||||
|
)
|
||||||
self.settings_renderer = SettingsRenderer(self.screen)
|
self.settings_renderer = SettingsRenderer(self.screen)
|
||||||
self.stats_renderer = StatsRenderer(self.screen)
|
self.stats_renderer = StatsRenderer(self.screen)
|
||||||
|
|
||||||
@ -62,26 +93,25 @@ class VillageSimulationApp:
|
|||||||
self.state: SimulationState | None = None
|
self.state: SimulationState | None = None
|
||||||
self.running = True
|
self.running = True
|
||||||
self.hovered_agent: dict | None = None
|
self.hovered_agent: dict | None = None
|
||||||
self._last_turn: int = -1 # Track turn changes for stats update
|
self._last_turn: int = -1
|
||||||
|
|
||||||
# Polling interval (ms)
|
# Polling interval (ms)
|
||||||
self.last_poll_time = 0
|
self.last_poll_time = 0
|
||||||
self.poll_interval = 100 # Poll every 100ms for smoother updates
|
self.poll_interval = 50 # Poll every 50ms for smoother updates
|
||||||
|
|
||||||
# Setup settings callbacks
|
# Setup settings callbacks
|
||||||
self._setup_settings_callbacks()
|
self._setup_settings_callbacks()
|
||||||
|
|
||||||
def _setup_settings_callbacks(self) -> None:
|
def _setup_settings_callbacks(self) -> None:
|
||||||
"""Set up callbacks for the settings panel."""
|
"""Set up callbacks for the settings panel."""
|
||||||
# Override the apply and reset callbacks
|
|
||||||
original_apply = self.settings_renderer._apply_config
|
original_apply = self.settings_renderer._apply_config
|
||||||
original_reset = self.settings_renderer._reset_config
|
original_reset = self.settings_renderer._reset_config
|
||||||
|
|
||||||
def apply_config():
|
def apply_config():
|
||||||
config = self.settings_renderer.get_config()
|
config = self.settings_renderer.get_config()
|
||||||
if self.client.update_config(config):
|
if self.client.update_config(config):
|
||||||
# Restart simulation with new config
|
num_agents = config.get("world", {}).get("initial_agents", 100)
|
||||||
if self.client.initialize():
|
if self.client.initialize(num_agents=num_agents):
|
||||||
self.state = self.client.get_state()
|
self.state = self.client.get_state()
|
||||||
self.settings_renderer.status_message = "Config applied & simulation restarted!"
|
self.settings_renderer.status_message = "Config applied & simulation restarted!"
|
||||||
self.settings_renderer.status_color = (80, 180, 100)
|
self.settings_renderer.status_color = (80, 180, 100)
|
||||||
@ -94,7 +124,6 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
def reset_config():
|
def reset_config():
|
||||||
if self.client.reset_config():
|
if self.client.reset_config():
|
||||||
# Reload config from server
|
|
||||||
config = self.client.get_config()
|
config = self.client.get_config()
|
||||||
if config:
|
if config:
|
||||||
self.settings_renderer.set_config(config)
|
self.settings_renderer.set_config(config)
|
||||||
@ -147,13 +176,12 @@ class VillageSimulationApp:
|
|||||||
# Advance one turn
|
# Advance one turn
|
||||||
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||||
if self.client.advance_turn():
|
if self.client.advance_turn():
|
||||||
# Immediately fetch new state
|
|
||||||
self.state = self.client.get_state()
|
self.state = self.client.get_state()
|
||||||
|
|
||||||
elif event.key == pygame.K_r:
|
elif event.key == pygame.K_r:
|
||||||
# Reset simulation
|
# Reset simulation
|
||||||
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||||
if self.client.initialize():
|
if self.client.initialize(num_agents=100):
|
||||||
self.state = self.client.get_state()
|
self.state = self.client.get_state()
|
||||||
self.stats_renderer.clear_history()
|
self.stats_renderer.clear_history()
|
||||||
self._last_turn = -1
|
self._last_turn = -1
|
||||||
@ -177,6 +205,10 @@ class VillageSimulationApp:
|
|||||||
self._load_config()
|
self._load_config()
|
||||||
self.settings_renderer.toggle()
|
self.settings_renderer.toggle()
|
||||||
|
|
||||||
|
elif event.key == pygame.K_f:
|
||||||
|
# Toggle fullscreen (alternative)
|
||||||
|
pygame.display.toggle_fullscreen()
|
||||||
|
|
||||||
def _handle_mouse_motion(self, event: pygame.event.Event) -> None:
|
def _handle_mouse_motion(self, event: pygame.event.Event) -> None:
|
||||||
"""Handle mouse motion for agent hover detection."""
|
"""Handle mouse motion for agent hover detection."""
|
||||||
if not self.state or self.settings_renderer.visible:
|
if not self.state or self.settings_renderer.visible:
|
||||||
@ -190,7 +222,7 @@ class VillageSimulationApp:
|
|||||||
if not self.map_rect.collidepoint(mouse_pos):
|
if not self.map_rect.collidepoint(mouse_pos):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check each agent
|
# Check each agent (only check visible ones for performance)
|
||||||
for agent in self.state.agents:
|
for agent in self.state.agents:
|
||||||
if not agent.get("is_alive", False):
|
if not agent.get("is_alive", False):
|
||||||
continue
|
continue
|
||||||
@ -242,8 +274,8 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
def draw(self) -> None:
|
def draw(self) -> None:
|
||||||
"""Draw all elements."""
|
"""Draw all elements."""
|
||||||
# Clear screen
|
# Clear screen with dark background
|
||||||
self.screen.fill((30, 35, 45))
|
self.screen.fill((15, 17, 23))
|
||||||
|
|
||||||
if self.state:
|
if self.state:
|
||||||
# Draw map
|
# Draw map
|
||||||
@ -252,7 +284,7 @@ class VillageSimulationApp:
|
|||||||
# Draw agents
|
# Draw agents
|
||||||
self.agent_renderer.draw(self.state)
|
self.agent_renderer.draw(self.state)
|
||||||
|
|
||||||
# Draw UI
|
# Draw UI panels
|
||||||
self.ui_renderer.draw(self.state)
|
self.ui_renderer.draw(self.state)
|
||||||
|
|
||||||
# Draw agent tooltip if hovering
|
# Draw agent tooltip if hovering
|
||||||
@ -272,16 +304,22 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
# Draw hints at bottom
|
# Draw hints at bottom
|
||||||
if not self.settings_renderer.visible and not self.stats_renderer.visible:
|
if not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||||
hint_font = pygame.font.Font(None, 18)
|
hint_font = pygame.font.Font(None, 16)
|
||||||
hint = hint_font.render("S: Settings | G: Statistics & Graphs", True, (100, 100, 120))
|
hint = hint_font.render(
|
||||||
self.screen.blit(hint, (5, self.screen.get_height() - 20))
|
"SPACE: Next Turn | R: Reset | M: Mode | S: Settings | G: Graphs | ESC: Quit",
|
||||||
|
True, (80, 85, 100)
|
||||||
|
)
|
||||||
|
self.screen.blit(hint, (10, self.screen_height - 22))
|
||||||
|
|
||||||
# Update display
|
# Update display
|
||||||
pygame.display.flip()
|
pygame.display.flip()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""Main game loop."""
|
"""Main game loop."""
|
||||||
print("Starting Village Simulation Frontend...")
|
print("=" * 60)
|
||||||
|
print(" VILLAGE SIMULATION - Economy, Religion & Diplomacy")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"\nScreen: {self.screen_width}x{self.screen_height} (Fullscreen)")
|
||||||
print("Connecting to backend at http://localhost:8000...")
|
print("Connecting to backend at http://localhost:8000...")
|
||||||
|
|
||||||
# Try to connect initially
|
# Try to connect initially
|
||||||
@ -293,10 +331,11 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
print("\nControls:")
|
print("\nControls:")
|
||||||
print(" SPACE - Advance turn")
|
print(" SPACE - Advance turn")
|
||||||
print(" R - Reset simulation")
|
print(" R - Reset simulation (100 agents)")
|
||||||
print(" M - Toggle auto/manual mode")
|
print(" M - Toggle auto/manual mode")
|
||||||
print(" S - Open settings")
|
print(" S - Open settings")
|
||||||
print(" G - Open statistics & graphs")
|
print(" G - Open statistics & graphs")
|
||||||
|
print(" F - Toggle fullscreen")
|
||||||
print(" ESC - Close panel / Quit")
|
print(" ESC - Close panel / Quit")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@ -311,7 +350,6 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Entry point for the frontend application."""
|
"""Entry point for the frontend application."""
|
||||||
# Get server URL from command line if provided
|
|
||||||
server_url = "http://localhost:8000"
|
server_url = "http://localhost:8000"
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
server_url = sys.argv[1]
|
server_url = sys.argv[1]
|
||||||
|
|||||||
@ -4,6 +4,13 @@ from .map_renderer import MapRenderer
|
|||||||
from .agent_renderer import AgentRenderer
|
from .agent_renderer import AgentRenderer
|
||||||
from .ui_renderer import UIRenderer
|
from .ui_renderer import UIRenderer
|
||||||
from .settings_renderer import SettingsRenderer
|
from .settings_renderer import SettingsRenderer
|
||||||
|
from .stats_renderer import StatsRenderer
|
||||||
|
|
||||||
__all__ = ["MapRenderer", "AgentRenderer", "UIRenderer", "SettingsRenderer"]
|
__all__ = [
|
||||||
|
"MapRenderer",
|
||||||
|
"AgentRenderer",
|
||||||
|
"UIRenderer",
|
||||||
|
"SettingsRenderer",
|
||||||
|
"StatsRenderer",
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
"""Agent renderer for the Village Simulation."""
|
"""Agent renderer for the Village Simulation.
|
||||||
|
|
||||||
|
Optimized for 100+ agents with faction/religion color coding.
|
||||||
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import pygame
|
import pygame
|
||||||
@ -9,42 +12,53 @@ if TYPE_CHECKING:
|
|||||||
from frontend.renderer.map_renderer import MapRenderer
|
from frontend.renderer.map_renderer import MapRenderer
|
||||||
|
|
||||||
|
|
||||||
# Profession colors (villager is the default now)
|
# Faction colors - matches backend FactionType
|
||||||
PROFESSION_COLORS = {
|
FACTION_COLORS = {
|
||||||
"villager": (100, 140, 180), # Blue-gray for generic villager
|
"northlands": (100, 160, 220), # Ice blue
|
||||||
"hunter": (180, 80, 80), # Red
|
"riverfolk": (70, 160, 180), # River teal
|
||||||
"gatherer": (80, 160, 80), # Green
|
"forestkin": (90, 160, 80), # Forest green
|
||||||
"woodcutter": (139, 90, 43), # Brown
|
"mountaineer": (150, 120, 90), # Mountain brown
|
||||||
"crafter": (160, 120, 200), # Purple
|
"plainsmen": (200, 180, 100), # Plains gold
|
||||||
|
"neutral": (120, 120, 120), # Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Religion colors
|
||||||
|
RELIGION_COLORS = {
|
||||||
|
"solaris": (255, 200, 80), # Golden sun
|
||||||
|
"aquarius": (80, 170, 240), # Ocean blue
|
||||||
|
"terranus": (160, 120, 70), # Earth brown
|
||||||
|
"ignis": (240, 100, 50), # Fire red
|
||||||
|
"naturis": (100, 200, 100), # Forest green
|
||||||
|
"atheist": (140, 140, 140), # Gray
|
||||||
}
|
}
|
||||||
|
|
||||||
# Corpse color
|
# Corpse color
|
||||||
CORPSE_COLOR = (60, 60, 60) # Dark gray
|
CORPSE_COLOR = (50, 50, 55)
|
||||||
|
|
||||||
# Status bar colors
|
# Action symbols (simplified for performance)
|
||||||
BAR_COLORS = {
|
|
||||||
"energy": (255, 220, 80), # Yellow
|
|
||||||
"hunger": (220, 140, 80), # Orange
|
|
||||||
"thirst": (80, 160, 220), # Blue
|
|
||||||
"heat": (220, 80, 80), # Red
|
|
||||||
}
|
|
||||||
|
|
||||||
# Action icons/symbols
|
|
||||||
ACTION_SYMBOLS = {
|
ACTION_SYMBOLS = {
|
||||||
"hunt": "🏹",
|
"hunt": "⚔",
|
||||||
"gather": "🍇",
|
"gather": "◆",
|
||||||
"chop_wood": "🪓",
|
"chop_wood": "▲",
|
||||||
"get_water": "💧",
|
"get_water": "◎",
|
||||||
"weave": "🧵",
|
"weave": "⊕",
|
||||||
"build_fire": "🔥",
|
"build_fire": "◈",
|
||||||
"trade": "💰",
|
"trade": "$",
|
||||||
"rest": "💤",
|
"rest": "○",
|
||||||
"sleep": "😴",
|
"sleep": "◐",
|
||||||
"consume": "🍖",
|
"consume": "●",
|
||||||
"dead": "💀",
|
"drill_oil": "⛏",
|
||||||
|
"refine": "⚙",
|
||||||
|
"pray": "✦",
|
||||||
|
"preach": "✧",
|
||||||
|
"negotiate": "⚖",
|
||||||
|
"declare_war": "⚔",
|
||||||
|
"make_peace": "☮",
|
||||||
|
"burn_fuel": "◈",
|
||||||
|
"dead": "✖",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fallback ASCII symbols for systems without emoji support
|
# Fallback ASCII
|
||||||
ACTION_LETTERS = {
|
ACTION_LETTERS = {
|
||||||
"hunt": "H",
|
"hunt": "H",
|
||||||
"gather": "G",
|
"gather": "G",
|
||||||
@ -56,12 +70,20 @@ ACTION_LETTERS = {
|
|||||||
"rest": "R",
|
"rest": "R",
|
||||||
"sleep": "Z",
|
"sleep": "Z",
|
||||||
"consume": "E",
|
"consume": "E",
|
||||||
|
"drill_oil": "O",
|
||||||
|
"refine": "U",
|
||||||
|
"pray": "P",
|
||||||
|
"preach": "!",
|
||||||
|
"negotiate": "N",
|
||||||
|
"declare_war": "!",
|
||||||
|
"make_peace": "+",
|
||||||
|
"burn_fuel": "B",
|
||||||
"dead": "X",
|
"dead": "X",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AgentRenderer:
|
class AgentRenderer:
|
||||||
"""Renders agents on the map with movement and action indicators."""
|
"""Renders agents on the map with faction/religion indicators."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -72,89 +94,116 @@ class AgentRenderer:
|
|||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.map_renderer = map_renderer
|
self.map_renderer = map_renderer
|
||||||
self.font = font
|
self.font = font
|
||||||
self.small_font = pygame.font.Font(None, 16)
|
self.small_font = pygame.font.Font(None, 14)
|
||||||
self.action_font = pygame.font.Font(None, 20)
|
self.action_font = pygame.font.Font(None, 16)
|
||||||
|
self.tooltip_font = pygame.font.Font(None, 18)
|
||||||
|
|
||||||
# Animation state
|
# Animation state
|
||||||
self.animation_tick = 0
|
self.animation_tick = 0
|
||||||
|
|
||||||
|
# Performance: limit detail level based on agent count
|
||||||
|
self.detail_level = 2 # 0=minimal, 1=basic, 2=full
|
||||||
|
|
||||||
|
def _get_faction_color(self, agent: dict) -> tuple[int, int, int]:
|
||||||
|
"""Get agent's faction color."""
|
||||||
|
# Faction is under diplomacy.faction (not faction.type)
|
||||||
|
diplomacy = agent.get("diplomacy", {})
|
||||||
|
faction = diplomacy.get("faction", "neutral")
|
||||||
|
return FACTION_COLORS.get(faction, FACTION_COLORS["neutral"])
|
||||||
|
|
||||||
|
def _get_religion_color(self, agent: dict) -> tuple[int, int, int]:
|
||||||
|
"""Get agent's religion color."""
|
||||||
|
# Religion type is under religion.religion (not religion.type)
|
||||||
|
religion_data = agent.get("religion", {})
|
||||||
|
religion = religion_data.get("religion", "atheist")
|
||||||
|
return RELIGION_COLORS.get(religion, RELIGION_COLORS["atheist"])
|
||||||
|
|
||||||
def _get_agent_color(self, agent: dict) -> tuple[int, int, int]:
|
def _get_agent_color(self, agent: dict) -> tuple[int, int, int]:
|
||||||
"""Get the color for an agent based on state."""
|
"""Get the main color for an agent (faction-based)."""
|
||||||
# Corpses are dark gray
|
|
||||||
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
|
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
|
||||||
return CORPSE_COLOR
|
return CORPSE_COLOR
|
||||||
|
|
||||||
profession = agent.get("profession", "villager")
|
base_color = self._get_faction_color(agent)
|
||||||
base_color = PROFESSION_COLORS.get(profession, (100, 140, 180))
|
|
||||||
|
|
||||||
if not agent.get("can_act", True):
|
if not agent.get("can_act", True):
|
||||||
# Slightly dimmed for exhausted agents
|
# Dimmed for exhausted agents
|
||||||
return tuple(int(c * 0.7) for c in base_color)
|
return tuple(int(c * 0.6) for c in base_color)
|
||||||
|
|
||||||
return base_color
|
return base_color
|
||||||
|
|
||||||
def _draw_status_bar(
|
def _draw_mini_bar(
|
||||||
self,
|
self,
|
||||||
x: int,
|
x: int,
|
||||||
y: int,
|
y: int,
|
||||||
width: int,
|
width: int,
|
||||||
height: int,
|
height: int,
|
||||||
value: int,
|
value: float,
|
||||||
max_value: int,
|
max_value: float,
|
||||||
color: tuple[int, int, int],
|
color: tuple[int, int, int],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw a single status bar."""
|
"""Draw a tiny status bar."""
|
||||||
|
if max_value <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
# Background
|
# Background
|
||||||
pygame.draw.rect(self.screen, (40, 40, 40), (x, y, width, height))
|
pygame.draw.rect(self.screen, (25, 25, 30), (x, y, width, height))
|
||||||
|
|
||||||
# Fill
|
# Fill
|
||||||
fill_width = int((value / max_value) * width) if max_value > 0 else 0
|
fill_width = int((value / max_value) * width)
|
||||||
if fill_width > 0:
|
if fill_width > 0:
|
||||||
pygame.draw.rect(self.screen, color, (x, y, fill_width, height))
|
# Color gradient based on value
|
||||||
|
ratio = value / max_value
|
||||||
|
if ratio < 0.25:
|
||||||
|
bar_color = (200, 60, 60) # Critical - red
|
||||||
|
elif ratio < 0.5:
|
||||||
|
bar_color = (200, 150, 60) # Low - orange
|
||||||
|
else:
|
||||||
|
bar_color = color
|
||||||
|
pygame.draw.rect(self.screen, bar_color, (x, y, fill_width, height))
|
||||||
|
|
||||||
# Border
|
def _draw_status_bars(
|
||||||
pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1)
|
self,
|
||||||
|
agent: dict,
|
||||||
def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None:
|
center_x: int,
|
||||||
"""Draw status bars below the agent."""
|
center_y: int,
|
||||||
|
size: int
|
||||||
|
) -> None:
|
||||||
|
"""Draw compact status bars below the agent."""
|
||||||
stats = agent.get("stats", {})
|
stats = agent.get("stats", {})
|
||||||
|
|
||||||
bar_width = size + 10
|
bar_width = size + 6
|
||||||
bar_height = 3
|
bar_height = 2
|
||||||
bar_spacing = 4
|
bar_spacing = 3
|
||||||
start_y = center_y + size // 2 + 4
|
start_y = center_y + size // 2 + 3
|
||||||
|
|
||||||
bars = [
|
bars = [
|
||||||
("energy", stats.get("energy", 0), stats.get("max_energy", 100)),
|
(stats.get("energy", 0), stats.get("max_energy", 100), (220, 200, 80)),
|
||||||
("hunger", stats.get("hunger", 0), stats.get("max_hunger", 100)),
|
(stats.get("hunger", 0), stats.get("max_hunger", 100), (200, 130, 80)),
|
||||||
("thirst", stats.get("thirst", 0), stats.get("max_thirst", 50)),
|
(stats.get("thirst", 0), stats.get("max_thirst", 100), (80, 160, 200)),
|
||||||
("heat", stats.get("heat", 0), stats.get("max_heat", 100)),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for i, (stat_name, value, max_value) in enumerate(bars):
|
for i, (value, max_value, color) in enumerate(bars):
|
||||||
bar_y = start_y + i * bar_spacing
|
bar_y = start_y + i * bar_spacing
|
||||||
self._draw_status_bar(
|
self._draw_mini_bar(
|
||||||
center_x - bar_width // 2,
|
center_x - bar_width // 2,
|
||||||
bar_y,
|
bar_y,
|
||||||
bar_width,
|
bar_width,
|
||||||
bar_height,
|
bar_height,
|
||||||
value,
|
value,
|
||||||
max_value,
|
max_value,
|
||||||
BAR_COLORS[stat_name],
|
color,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _draw_action_indicator(
|
def _draw_action_bubble(
|
||||||
self,
|
self,
|
||||||
agent: dict,
|
agent: dict,
|
||||||
center_x: int,
|
center_x: int,
|
||||||
center_y: int,
|
center_y: int,
|
||||||
agent_size: int,
|
agent_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw action indicator above the agent."""
|
"""Draw action indicator bubble above agent."""
|
||||||
current_action = agent.get("current_action", {})
|
current_action = agent.get("current_action", {})
|
||||||
action_type = current_action.get("action_type", "")
|
action_type = current_action.get("action_type", "")
|
||||||
is_moving = current_action.get("is_moving", False)
|
|
||||||
message = current_action.get("message", "")
|
|
||||||
|
|
||||||
if not action_type:
|
if not action_type:
|
||||||
return
|
return
|
||||||
@ -162,171 +211,192 @@ class AgentRenderer:
|
|||||||
# Get action symbol
|
# Get action symbol
|
||||||
symbol = ACTION_LETTERS.get(action_type, "?")
|
symbol = ACTION_LETTERS.get(action_type, "?")
|
||||||
|
|
||||||
# Draw action bubble above agent
|
# Position above agent
|
||||||
bubble_y = center_y - agent_size // 2 - 20
|
bubble_y = center_y - agent_size // 2 - 12
|
||||||
|
|
||||||
# Animate if moving
|
# Animate if moving
|
||||||
|
is_moving = current_action.get("is_moving", False)
|
||||||
if is_moving:
|
if is_moving:
|
||||||
# Bouncing animation
|
offset = int(2 * math.sin(self.animation_tick * 0.3))
|
||||||
offset = int(3 * math.sin(self.animation_tick * 0.3))
|
|
||||||
bubble_y += offset
|
bubble_y += offset
|
||||||
|
|
||||||
# Draw bubble background
|
# Draw small bubble
|
||||||
bubble_width = 22
|
bubble_w, bubble_h = 14, 12
|
||||||
bubble_height = 18
|
|
||||||
bubble_rect = pygame.Rect(
|
bubble_rect = pygame.Rect(
|
||||||
center_x - bubble_width // 2,
|
center_x - bubble_w // 2,
|
||||||
bubble_y - bubble_height // 2,
|
bubble_y - bubble_h // 2,
|
||||||
bubble_width,
|
bubble_w,
|
||||||
bubble_height,
|
bubble_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Color based on action success/failure
|
# Color based on action type
|
||||||
if "Failed" in message:
|
if action_type in ["pray", "preach"]:
|
||||||
bg_color = (120, 60, 60)
|
bg_color = (60, 50, 80)
|
||||||
|
border_color = (120, 100, 160)
|
||||||
|
elif action_type in ["negotiate", "make_peace"]:
|
||||||
|
bg_color = (50, 70, 80)
|
||||||
|
border_color = (100, 160, 180)
|
||||||
|
elif action_type in ["declare_war"]:
|
||||||
|
bg_color = (80, 40, 40)
|
||||||
border_color = (180, 80, 80)
|
border_color = (180, 80, 80)
|
||||||
|
elif action_type in ["drill_oil", "refine", "burn_fuel"]:
|
||||||
|
bg_color = (60, 55, 40)
|
||||||
|
border_color = (140, 120, 80)
|
||||||
elif is_moving:
|
elif is_moving:
|
||||||
bg_color = (60, 80, 120)
|
bg_color = (50, 60, 80)
|
||||||
border_color = (100, 140, 200)
|
border_color = (100, 140, 200)
|
||||||
else:
|
else:
|
||||||
bg_color = (50, 70, 50)
|
bg_color = (40, 55, 45)
|
||||||
border_color = (80, 140, 80)
|
border_color = (80, 130, 90)
|
||||||
|
|
||||||
pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=4)
|
pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=3)
|
||||||
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=4)
|
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=3)
|
||||||
|
|
||||||
# Draw action letter
|
# Draw symbol
|
||||||
text = self.action_font.render(symbol, True, (255, 255, 255))
|
text = self.action_font.render(symbol, True, (230, 230, 230))
|
||||||
text_rect = text.get_rect(center=(center_x, bubble_y))
|
text_rect = text.get_rect(center=(center_x, bubble_y))
|
||||||
self.screen.blit(text, text_rect)
|
self.screen.blit(text, text_rect)
|
||||||
|
|
||||||
# Draw movement trail if moving
|
def _draw_religion_indicator(
|
||||||
if is_moving:
|
|
||||||
target_pos = current_action.get("target_position")
|
|
||||||
if target_pos:
|
|
||||||
target_x, target_y = self.map_renderer.grid_to_screen(
|
|
||||||
target_pos.get("x", 0),
|
|
||||||
target_pos.get("y", 0),
|
|
||||||
)
|
|
||||||
# Draw dotted line to target
|
|
||||||
self._draw_dotted_line(
|
|
||||||
(center_x, center_y),
|
|
||||||
(target_x, target_y),
|
|
||||||
(100, 100, 100),
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _draw_dotted_line(
|
|
||||||
self,
|
|
||||||
start: tuple[int, int],
|
|
||||||
end: tuple[int, int],
|
|
||||||
color: tuple[int, int, int],
|
|
||||||
dot_spacing: int = 5,
|
|
||||||
) -> None:
|
|
||||||
"""Draw a dotted line between two points."""
|
|
||||||
dx = end[0] - start[0]
|
|
||||||
dy = end[1] - start[1]
|
|
||||||
distance = max(1, int((dx ** 2 + dy ** 2) ** 0.5))
|
|
||||||
|
|
||||||
for i in range(0, distance, dot_spacing * 2):
|
|
||||||
t = i / distance
|
|
||||||
x = int(start[0] + dx * t)
|
|
||||||
y = int(start[1] + dy * t)
|
|
||||||
pygame.draw.circle(self.screen, color, (x, y), 1)
|
|
||||||
|
|
||||||
def _draw_last_action_result(
|
|
||||||
self,
|
self,
|
||||||
agent: dict,
|
agent: dict,
|
||||||
center_x: int,
|
center_x: int,
|
||||||
center_y: int,
|
center_y: int,
|
||||||
agent_size: int,
|
agent_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw the last action result as floating text."""
|
"""Draw a small religion indicator (faith glow)."""
|
||||||
result = agent.get("last_action_result", "")
|
faith = agent.get("faith", 50)
|
||||||
if not result:
|
religion_color = self._get_religion_color(agent)
|
||||||
return
|
|
||||||
|
|
||||||
# Truncate long messages
|
# Only show for agents with significant faith
|
||||||
if len(result) > 25:
|
if faith > 70:
|
||||||
result = result[:22] + "..."
|
# Divine glow effect
|
||||||
|
glow_alpha = int((faith / 100) * 60)
|
||||||
|
glow_surface = pygame.Surface(
|
||||||
|
(agent_size * 2, agent_size * 2),
|
||||||
|
pygame.SRCALPHA
|
||||||
|
)
|
||||||
|
pygame.draw.circle(
|
||||||
|
glow_surface,
|
||||||
|
(*religion_color, glow_alpha),
|
||||||
|
(agent_size, agent_size),
|
||||||
|
agent_size,
|
||||||
|
)
|
||||||
|
self.screen.blit(
|
||||||
|
glow_surface,
|
||||||
|
(center_x - agent_size, center_y - agent_size),
|
||||||
|
)
|
||||||
|
|
||||||
# Draw text below status bars
|
# Small religion dot indicator
|
||||||
text_y = center_y + agent_size // 2 + 22
|
dot_x = center_x + agent_size // 2 - 2
|
||||||
|
dot_y = center_y - agent_size // 2 + 2
|
||||||
|
pygame.draw.circle(self.screen, religion_color, (dot_x, dot_y), 3)
|
||||||
|
pygame.draw.circle(self.screen, (30, 30, 35), (dot_x, dot_y), 3, 1)
|
||||||
|
|
||||||
text = self.small_font.render(result, True, (180, 180, 180))
|
def _draw_war_indicator(self, agent: dict, center_x: int, center_y: int) -> None:
|
||||||
text_rect = text.get_rect(center=(center_x, text_y))
|
"""Draw indicator if agent's faction is at war."""
|
||||||
|
diplomacy = agent.get("diplomacy", {})
|
||||||
|
faction = diplomacy.get("faction", "neutral")
|
||||||
|
at_war = agent.get("at_war", False)
|
||||||
|
|
||||||
# Background for readability
|
if at_war:
|
||||||
bg_rect = text_rect.inflate(4, 2)
|
# Red war indicator
|
||||||
pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect)
|
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.15)
|
||||||
|
war_color = (int(200 * pulse), int(50 * pulse), int(50 * pulse))
|
||||||
self.screen.blit(text, text_rect)
|
pygame.draw.circle(
|
||||||
|
self.screen, war_color,
|
||||||
|
(center_x - 6, center_y - 6),
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
def draw(self, state: "SimulationState") -> None:
|
def draw(self, state: "SimulationState") -> None:
|
||||||
"""Draw all agents (including corpses for one turn)."""
|
"""Draw all agents (optimized for many agents)."""
|
||||||
self.animation_tick += 1
|
self.animation_tick += 1
|
||||||
|
|
||||||
cell_w, cell_h = self.map_renderer.get_cell_size()
|
cell_w, cell_h = self.map_renderer.get_cell_size()
|
||||||
agent_size = min(cell_w, cell_h) - 8
|
agent_size = min(cell_w, cell_h) - 6
|
||||||
agent_size = max(10, min(agent_size, 30)) # Clamp size
|
agent_size = max(8, min(agent_size, 24))
|
||||||
|
|
||||||
|
# Adjust detail level based on agent count
|
||||||
|
living_count = len(state.get_living_agents())
|
||||||
|
if living_count > 150:
|
||||||
|
self.detail_level = 0
|
||||||
|
elif living_count > 80:
|
||||||
|
self.detail_level = 1
|
||||||
|
else:
|
||||||
|
self.detail_level = 2
|
||||||
|
|
||||||
|
# Separate corpses and living agents
|
||||||
|
corpses = []
|
||||||
|
living = []
|
||||||
|
|
||||||
for agent in state.agents:
|
for agent in state.agents:
|
||||||
is_corpse = agent.get("is_corpse", False)
|
if agent.get("is_corpse", False):
|
||||||
is_alive = agent.get("is_alive", True)
|
corpses.append(agent)
|
||||||
|
elif agent.get("is_alive", True):
|
||||||
|
living.append(agent)
|
||||||
|
|
||||||
# Get screen position from agent's current position
|
# Draw corpses first (behind living agents)
|
||||||
|
for agent in corpses:
|
||||||
|
pos = agent.get("position", {"x": 0, "y": 0})
|
||||||
|
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
||||||
|
self._draw_corpse(agent, screen_x, screen_y, agent_size)
|
||||||
|
|
||||||
|
# Draw living agents
|
||||||
|
for agent in living:
|
||||||
pos = agent.get("position", {"x": 0, "y": 0})
|
pos = agent.get("position", {"x": 0, "y": 0})
|
||||||
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
||||||
|
|
||||||
if is_corpse:
|
# Religion glow (full detail only)
|
||||||
# Draw corpse with death indicator
|
if self.detail_level >= 2:
|
||||||
self._draw_corpse(agent, screen_x, screen_y, agent_size)
|
self._draw_religion_indicator(agent, screen_x, screen_y, agent_size)
|
||||||
continue
|
|
||||||
|
|
||||||
if not is_alive:
|
# Action bubble (basic+ detail)
|
||||||
continue
|
if self.detail_level >= 1:
|
||||||
|
self._draw_action_bubble(agent, screen_x, screen_y, agent_size)
|
||||||
|
|
||||||
# Draw movement trail/line to target first (behind agent)
|
# Main agent circle with faction color
|
||||||
self._draw_action_indicator(agent, screen_x, screen_y, agent_size)
|
|
||||||
|
|
||||||
# Draw agent circle
|
|
||||||
color = self._get_agent_color(agent)
|
color = self._get_agent_color(agent)
|
||||||
pygame.draw.circle(self.screen, color, (screen_x, screen_y), agent_size // 2)
|
pygame.draw.circle(
|
||||||
|
self.screen, color,
|
||||||
|
(screen_x, screen_y),
|
||||||
|
agent_size // 2,
|
||||||
|
)
|
||||||
|
|
||||||
# Draw border - animated if moving
|
# Border - based on state
|
||||||
current_action = agent.get("current_action", {})
|
current_action = agent.get("current_action", {})
|
||||||
is_moving = current_action.get("is_moving", False)
|
is_moving = current_action.get("is_moving", False)
|
||||||
|
|
||||||
if is_moving:
|
if is_moving:
|
||||||
# Pulsing border when moving
|
pulse = int(180 + 75 * math.sin(self.animation_tick * 0.2))
|
||||||
pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2))
|
|
||||||
border_color = (pulse, pulse, 255)
|
border_color = (pulse, pulse, 255)
|
||||||
elif agent.get("can_act"):
|
elif agent.get("can_act"):
|
||||||
border_color = (255, 255, 255)
|
border_color = (200, 200, 210)
|
||||||
else:
|
else:
|
||||||
border_color = (100, 100, 100)
|
border_color = (80, 80, 85)
|
||||||
|
|
||||||
pygame.draw.circle(self.screen, border_color, (screen_x, screen_y), agent_size // 2, 2)
|
pygame.draw.circle(
|
||||||
|
self.screen, border_color,
|
||||||
|
(screen_x, screen_y),
|
||||||
|
agent_size // 2,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
# Draw money indicator (small coin icon)
|
# Money indicator
|
||||||
money = agent.get("money", 0)
|
money = agent.get("money", 0)
|
||||||
if money > 0:
|
if money > 50:
|
||||||
coin_x = screen_x + agent_size // 2 - 4
|
coin_x = screen_x + agent_size // 2 - 2
|
||||||
coin_y = screen_y - agent_size // 2 - 4
|
coin_y = screen_y - agent_size // 2 - 2
|
||||||
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 4)
|
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 3)
|
||||||
pygame.draw.circle(self.screen, (200, 160, 0), (coin_x, coin_y), 4, 1)
|
|
||||||
|
|
||||||
# Draw "V" for villager
|
# War indicator
|
||||||
text = self.small_font.render("V", True, (255, 255, 255))
|
if self.detail_level >= 1:
|
||||||
text_rect = text.get_rect(center=(screen_x, screen_y))
|
self._draw_war_indicator(agent, screen_x, screen_y)
|
||||||
self.screen.blit(text, text_rect)
|
|
||||||
|
|
||||||
# Draw status bars
|
# Status bars (basic+ detail)
|
||||||
|
if self.detail_level >= 1:
|
||||||
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
|
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
|
||||||
|
|
||||||
# Draw last action result
|
|
||||||
self._draw_last_action_result(agent, screen_x, screen_y, agent_size)
|
|
||||||
|
|
||||||
def _draw_corpse(
|
def _draw_corpse(
|
||||||
self,
|
self,
|
||||||
agent: dict,
|
agent: dict,
|
||||||
@ -334,97 +404,117 @@ class AgentRenderer:
|
|||||||
center_y: int,
|
center_y: int,
|
||||||
agent_size: int,
|
agent_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw a corpse with death reason displayed."""
|
"""Draw a corpse marker."""
|
||||||
# Draw corpse circle (dark gray)
|
# Simple X marker
|
||||||
pygame.draw.circle(self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 2)
|
pygame.draw.circle(
|
||||||
|
self.screen, CORPSE_COLOR,
|
||||||
|
(center_x, center_y),
|
||||||
|
agent_size // 3,
|
||||||
|
)
|
||||||
|
pygame.draw.circle(
|
||||||
|
self.screen, (100, 50, 50),
|
||||||
|
(center_x, center_y),
|
||||||
|
agent_size // 3,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
# Draw red X border
|
# X symbol
|
||||||
pygame.draw.circle(self.screen, (150, 50, 50), (center_x, center_y), agent_size // 2, 2)
|
half = agent_size // 4
|
||||||
|
pygame.draw.line(
|
||||||
# Draw skull symbol
|
self.screen, (120, 60, 60),
|
||||||
text = self.action_font.render("X", True, (180, 80, 80))
|
(center_x - half, center_y - half),
|
||||||
text_rect = text.get_rect(center=(center_x, center_y))
|
(center_x + half, center_y + half),
|
||||||
self.screen.blit(text, text_rect)
|
1,
|
||||||
|
)
|
||||||
# Draw death reason above corpse
|
pygame.draw.line(
|
||||||
death_reason = agent.get("death_reason", "unknown")
|
self.screen, (120, 60, 60),
|
||||||
name = agent.get("name", "Unknown")
|
(center_x + half, center_y - half),
|
||||||
|
(center_x - half, center_y + half),
|
||||||
# Death indicator bubble
|
1,
|
||||||
bubble_y = center_y - agent_size // 2 - 20
|
)
|
||||||
bubble_text = f"💀 {death_reason}"
|
|
||||||
|
|
||||||
text = self.small_font.render(bubble_text, True, (255, 100, 100))
|
|
||||||
text_rect = text.get_rect(center=(center_x, bubble_y))
|
|
||||||
|
|
||||||
# Background for readability
|
|
||||||
bg_rect = text_rect.inflate(8, 4)
|
|
||||||
pygame.draw.rect(self.screen, (40, 20, 20), bg_rect, border_radius=3)
|
|
||||||
pygame.draw.rect(self.screen, (120, 50, 50), bg_rect, 1, border_radius=3)
|
|
||||||
|
|
||||||
self.screen.blit(text, text_rect)
|
|
||||||
|
|
||||||
# Draw name below
|
|
||||||
name_y = center_y + agent_size // 2 + 8
|
|
||||||
name_text = self.small_font.render(name, True, (150, 150, 150))
|
|
||||||
name_rect = name_text.get_rect(center=(center_x, name_y))
|
|
||||||
self.screen.blit(name_text, name_rect)
|
|
||||||
|
|
||||||
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
|
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
|
||||||
"""Draw a tooltip for an agent when hovered."""
|
"""Draw a detailed tooltip for hovered agent."""
|
||||||
# Build tooltip text
|
lines = []
|
||||||
lines = [
|
|
||||||
agent.get("name", "Unknown"),
|
# Name and faction
|
||||||
f"Profession: {agent.get('profession', '?').capitalize()}",
|
name = agent.get("name", "Unknown")
|
||||||
f"Money: {agent.get('money', 0)} coins",
|
diplomacy = agent.get("diplomacy", {})
|
||||||
"",
|
faction = diplomacy.get("faction", "neutral").title()
|
||||||
]
|
lines.append(f"{name}")
|
||||||
|
lines.append(f"Faction: {faction}")
|
||||||
|
|
||||||
|
# Religion and faith
|
||||||
|
religion_data = agent.get("religion", {})
|
||||||
|
religion = religion_data.get("religion", "atheist").title()
|
||||||
|
faith = religion_data.get("faith", 50)
|
||||||
|
lines.append(f"Religion: {religion} ({faith}% faith)")
|
||||||
|
|
||||||
|
# Money
|
||||||
|
money = agent.get("money", 0)
|
||||||
|
lines.append(f"Money: {money} coins")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
stats = agent.get("stats", {})
|
||||||
|
lines.append(f"Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}")
|
||||||
|
lines.append(f"Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}")
|
||||||
|
lines.append(f"Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 100)}")
|
||||||
|
lines.append(f"Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}")
|
||||||
|
|
||||||
# Current action
|
# Current action
|
||||||
current_action = agent.get("current_action", {})
|
current_action = agent.get("current_action", {})
|
||||||
action_type = current_action.get("action_type", "")
|
action_type = current_action.get("action_type", "")
|
||||||
if action_type:
|
if action_type:
|
||||||
action_msg = current_action.get("message", action_type)
|
|
||||||
lines.append(f"Action: {action_msg[:40]}")
|
|
||||||
if current_action.get("is_moving"):
|
|
||||||
lines.append(" (moving to location)")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
lines.append(f"Action: {action_type.replace('_', ' ').title()}")
|
||||||
|
if current_action.get("is_moving"):
|
||||||
|
lines.append(" (moving)")
|
||||||
|
|
||||||
lines.append("Stats:")
|
# Inventory summary
|
||||||
stats = agent.get("stats", {})
|
|
||||||
lines.append(f" Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}")
|
|
||||||
lines.append(f" Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}")
|
|
||||||
lines.append(f" Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 50)}")
|
|
||||||
lines.append(f" Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}")
|
|
||||||
|
|
||||||
inventory = agent.get("inventory", [])
|
inventory = agent.get("inventory", [])
|
||||||
if inventory:
|
if inventory:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Inventory:")
|
lines.append("Inventory:")
|
||||||
for item in inventory[:5]:
|
for item in inventory[:4]:
|
||||||
lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}")
|
item_type = item.get("type", "?")
|
||||||
|
qty = item.get("quantity", 0)
|
||||||
|
lines.append(f" {item_type}: {qty}")
|
||||||
|
if len(inventory) > 4:
|
||||||
|
lines.append(f" ...+{len(inventory) - 4} more")
|
||||||
|
|
||||||
# Last action result
|
# Calculate size
|
||||||
last_result = agent.get("last_action_result", "")
|
|
||||||
if last_result:
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"Last: {last_result[:35]}")
|
|
||||||
|
|
||||||
# Calculate tooltip size
|
|
||||||
line_height = 16
|
line_height = 16
|
||||||
max_width = max(self.small_font.size(line)[0] for line in lines) + 20
|
max_width = max(self.tooltip_font.size(line)[0] for line in lines if line) + 24
|
||||||
height = len(lines) * line_height + 10
|
height = len(lines) * line_height + 16
|
||||||
|
|
||||||
# Position tooltip near mouse but not off screen
|
# Position
|
||||||
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 5)
|
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 10)
|
||||||
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 5)
|
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 10)
|
||||||
|
|
||||||
# Draw background
|
# Background with faction color accent
|
||||||
tooltip_rect = pygame.Rect(x, y, max_width, height)
|
tooltip_rect = pygame.Rect(x, y, max_width, height)
|
||||||
pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect)
|
pygame.draw.rect(self.screen, (25, 28, 35), tooltip_rect, border_radius=6)
|
||||||
pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1)
|
|
||||||
|
# Faction color accent bar
|
||||||
|
faction_color = self._get_faction_color(agent)
|
||||||
|
pygame.draw.rect(
|
||||||
|
self.screen, faction_color,
|
||||||
|
(x, y, 4, height),
|
||||||
|
border_top_left_radius=6,
|
||||||
|
border_bottom_left_radius=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
pygame.draw.rect(
|
||||||
|
self.screen, (60, 70, 85),
|
||||||
|
tooltip_rect, 1, border_radius=6,
|
||||||
|
)
|
||||||
|
|
||||||
# Draw text
|
# Draw text
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
text = self.small_font.render(line, True, (220, 220, 220))
|
if not line:
|
||||||
self.screen.blit(text, (x + 10, y + 5 + i * line_height))
|
continue
|
||||||
|
color = (220, 220, 225) if i == 0 else (170, 175, 185)
|
||||||
|
text = self.tooltip_font.render(line, True, color)
|
||||||
|
self.screen.blit(text, (x + 12, y + 8 + i * line_height))
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
"""Map renderer for the Village Simulation."""
|
"""Map renderer for the Village Simulation.
|
||||||
|
|
||||||
|
Beautiful dark theme with oil fields, temples, and terrain features.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
import pygame
|
import pygame
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@ -7,29 +12,58 @@ if TYPE_CHECKING:
|
|||||||
from frontend.client import SimulationState
|
from frontend.client import SimulationState
|
||||||
|
|
||||||
|
|
||||||
# Color palette
|
# Color palette - Cyberpunk dark theme
|
||||||
class Colors:
|
class Colors:
|
||||||
# Background colors
|
# Background colors
|
||||||
DAY_BG = (180, 200, 160) # Soft green for day
|
DAY_BG = (28, 35, 42)
|
||||||
NIGHT_BG = (40, 45, 60) # Dark blue for night
|
NIGHT_BG = (12, 14, 20)
|
||||||
GRID_LINE = (120, 140, 110) # Subtle grid lines
|
|
||||||
GRID_LINE_NIGHT = (60, 65, 80)
|
|
||||||
|
|
||||||
# Terrain features (for visual variety)
|
# Terrain
|
||||||
GRASS_LIGHT = (160, 190, 140)
|
GRASS_LIGHT = (32, 45, 38)
|
||||||
GRASS_DARK = (140, 170, 120)
|
GRASS_DARK = (26, 38, 32)
|
||||||
WATER_SPOT = (100, 140, 180)
|
GRASS_ACCENT = (38, 52, 44)
|
||||||
|
WATER_SPOT = (25, 45, 65)
|
||||||
|
WATER_DEEP = (18, 35, 55)
|
||||||
|
|
||||||
|
# Grid
|
||||||
|
GRID_LINE = (45, 55, 60)
|
||||||
|
GRID_LINE_NIGHT = (25, 30, 38)
|
||||||
|
|
||||||
|
# Special locations
|
||||||
|
OIL_FIELD = (35, 35, 35)
|
||||||
|
OIL_GLOW = (80, 70, 45)
|
||||||
|
TEMPLE_GLOW = (100, 80, 140)
|
||||||
|
|
||||||
|
# Religion colors
|
||||||
|
RELIGIONS = {
|
||||||
|
"solaris": (255, 180, 50), # Golden sun
|
||||||
|
"aquarius": (50, 150, 220), # Ocean blue
|
||||||
|
"terranus": (140, 100, 60), # Earth brown
|
||||||
|
"ignis": (220, 80, 40), # Fire red
|
||||||
|
"naturis": (80, 180, 80), # Forest green
|
||||||
|
"atheist": (100, 100, 100), # Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Faction colors
|
||||||
|
FACTIONS = {
|
||||||
|
"northlands": (100, 150, 200), # Ice blue
|
||||||
|
"riverfolk": (60, 140, 170), # River teal
|
||||||
|
"forestkin": (80, 140, 70), # Forest green
|
||||||
|
"mountaineer": (130, 110, 90), # Mountain brown
|
||||||
|
"plainsmen": (180, 160, 100), # Plains gold
|
||||||
|
"neutral": (100, 100, 100), # Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MapRenderer:
|
class MapRenderer:
|
||||||
"""Renders the map/terrain background."""
|
"""Renders the map/terrain background with special locations."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
screen: pygame.Surface,
|
screen: pygame.Surface,
|
||||||
map_rect: pygame.Rect,
|
map_rect: pygame.Rect,
|
||||||
world_width: int = 20,
|
world_width: int = 30,
|
||||||
world_height: int = 20,
|
world_height: int = 30,
|
||||||
):
|
):
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.map_rect = map_rect
|
self.map_rect = map_rect
|
||||||
@ -38,24 +72,40 @@ class MapRenderer:
|
|||||||
self._cell_width = map_rect.width / world_width
|
self._cell_width = map_rect.width / world_width
|
||||||
self._cell_height = map_rect.height / world_height
|
self._cell_height = map_rect.height / world_height
|
||||||
|
|
||||||
# Pre-generate some terrain variation
|
# Animation state
|
||||||
|
self.animation_tick = 0
|
||||||
|
|
||||||
|
# Pre-generate terrain
|
||||||
self._terrain_cache = self._generate_terrain()
|
self._terrain_cache = self._generate_terrain()
|
||||||
|
|
||||||
|
# Surface cache for static elements
|
||||||
|
self._terrain_surface: pygame.Surface | None = None
|
||||||
|
self._cached_dimensions = (world_width, world_height, map_rect.width, map_rect.height)
|
||||||
|
|
||||||
def _generate_terrain(self) -> list[list[int]]:
|
def _generate_terrain(self) -> list[list[int]]:
|
||||||
"""Generate simple terrain variation (0 = light, 1 = dark, 2 = water)."""
|
"""Generate terrain variation using noise-like pattern."""
|
||||||
import random
|
random.seed(42) # Consistent terrain
|
||||||
terrain = []
|
terrain = []
|
||||||
|
|
||||||
for y in range(self.world_height):
|
for y in range(self.world_height):
|
||||||
row = []
|
row = []
|
||||||
for x in range(self.world_width):
|
for x in range(self.world_width):
|
||||||
# Simple pattern: mostly grass with occasional water spots
|
# Create organic-looking patterns
|
||||||
if random.random() < 0.05:
|
noise = (
|
||||||
row.append(2) # Water spot
|
math.sin(x * 0.3) * math.cos(y * 0.3) +
|
||||||
elif (x + y) % 3 == 0:
|
math.sin(x * 0.7 + y * 0.5) * 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
if noise > 0.8:
|
||||||
|
row.append(2) # Water
|
||||||
|
elif noise > 0.3:
|
||||||
row.append(1) # Dark grass
|
row.append(1) # Dark grass
|
||||||
|
elif noise < -0.5:
|
||||||
|
row.append(3) # Accent grass
|
||||||
else:
|
else:
|
||||||
row.append(0) # Light grass
|
row.append(0) # Light grass
|
||||||
terrain.append(row)
|
terrain.append(row)
|
||||||
|
|
||||||
return terrain
|
return terrain
|
||||||
|
|
||||||
def update_dimensions(self, world_width: int, world_height: int) -> None:
|
def update_dimensions(self, world_width: int, world_height: int) -> None:
|
||||||
@ -66,6 +116,7 @@ class MapRenderer:
|
|||||||
self._cell_width = self.map_rect.width / world_width
|
self._cell_width = self.map_rect.width / world_width
|
||||||
self._cell_height = self.map_rect.height / world_height
|
self._cell_height = self.map_rect.height / world_height
|
||||||
self._terrain_cache = self._generate_terrain()
|
self._terrain_cache = self._generate_terrain()
|
||||||
|
self._terrain_surface = None # Invalidate cache
|
||||||
|
|
||||||
def grid_to_screen(self, grid_x: int, grid_y: int) -> tuple[int, int]:
|
def grid_to_screen(self, grid_x: int, grid_y: int) -> tuple[int, int]:
|
||||||
"""Convert grid coordinates to screen coordinates (center of cell)."""
|
"""Convert grid coordinates to screen coordinates (center of cell)."""
|
||||||
@ -77,70 +128,212 @@ class MapRenderer:
|
|||||||
"""Get the size of a single cell."""
|
"""Get the size of a single cell."""
|
||||||
return int(self._cell_width), int(self._cell_height)
|
return int(self._cell_width), int(self._cell_height)
|
||||||
|
|
||||||
def draw(self, state: "SimulationState") -> None:
|
def _render_terrain_surface(self, is_night: bool) -> pygame.Surface:
|
||||||
"""Draw the map background."""
|
"""Render terrain to a cached surface."""
|
||||||
is_night = state.time_of_day == "night"
|
surface = pygame.Surface((self.map_rect.width, self.map_rect.height))
|
||||||
|
|
||||||
# Fill background
|
# Fill background
|
||||||
bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG
|
bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG
|
||||||
pygame.draw.rect(self.screen, bg_color, self.map_rect)
|
surface.fill(bg_color)
|
||||||
|
|
||||||
# Draw terrain cells
|
# Draw terrain cells
|
||||||
for y in range(self.world_height):
|
for y in range(self.world_height):
|
||||||
for x in range(self.world_width):
|
for x in range(self.world_width):
|
||||||
cell_rect = pygame.Rect(
|
cell_rect = pygame.Rect(
|
||||||
self.map_rect.left + x * self._cell_width,
|
x * self._cell_width,
|
||||||
self.map_rect.top + y * self._cell_height,
|
y * self._cell_height,
|
||||||
self._cell_width + 1, # +1 to avoid gaps
|
self._cell_width + 1,
|
||||||
self._cell_height + 1,
|
self._cell_height + 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
terrain_type = self._terrain_cache[y][x]
|
terrain_type = self._terrain_cache[y][x]
|
||||||
|
|
||||||
if is_night:
|
if is_night:
|
||||||
# Darker colors at night
|
|
||||||
if terrain_type == 2:
|
if terrain_type == 2:
|
||||||
color = (60, 80, 110)
|
color = (15, 25, 40)
|
||||||
elif terrain_type == 1:
|
elif terrain_type == 1:
|
||||||
color = (35, 40, 55)
|
color = (18, 25, 22)
|
||||||
|
elif terrain_type == 3:
|
||||||
|
color = (22, 30, 26)
|
||||||
else:
|
else:
|
||||||
color = (45, 50, 65)
|
color = (20, 28, 24)
|
||||||
else:
|
else:
|
||||||
if terrain_type == 2:
|
if terrain_type == 2:
|
||||||
color = Colors.WATER_SPOT
|
color = Colors.WATER_SPOT
|
||||||
elif terrain_type == 1:
|
elif terrain_type == 1:
|
||||||
color = Colors.GRASS_DARK
|
color = Colors.GRASS_DARK
|
||||||
|
elif terrain_type == 3:
|
||||||
|
color = Colors.GRASS_ACCENT
|
||||||
else:
|
else:
|
||||||
color = Colors.GRASS_LIGHT
|
color = Colors.GRASS_LIGHT
|
||||||
|
|
||||||
pygame.draw.rect(self.screen, color, cell_rect)
|
pygame.draw.rect(surface, color, cell_rect)
|
||||||
|
|
||||||
# Draw grid lines
|
# Draw subtle grid
|
||||||
grid_color = Colors.GRID_LINE_NIGHT if is_night else Colors.GRID_LINE
|
grid_color = Colors.GRID_LINE_NIGHT if is_night else Colors.GRID_LINE
|
||||||
|
|
||||||
# Vertical lines
|
|
||||||
for x in range(self.world_width + 1):
|
for x in range(self.world_width + 1):
|
||||||
start_x = self.map_rect.left + x * self._cell_width
|
start_x = x * self._cell_width
|
||||||
pygame.draw.line(
|
pygame.draw.line(
|
||||||
self.screen,
|
surface,
|
||||||
grid_color,
|
grid_color,
|
||||||
(start_x, self.map_rect.top),
|
(start_x, 0),
|
||||||
(start_x, self.map_rect.bottom),
|
(start_x, self.map_rect.height),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Horizontal lines
|
|
||||||
for y in range(self.world_height + 1):
|
for y in range(self.world_height + 1):
|
||||||
start_y = self.map_rect.top + y * self._cell_height
|
start_y = y * self._cell_height
|
||||||
pygame.draw.line(
|
pygame.draw.line(
|
||||||
self.screen,
|
surface,
|
||||||
grid_color,
|
grid_color,
|
||||||
(self.map_rect.left, start_y),
|
(0, start_y),
|
||||||
(self.map_rect.right, start_y),
|
(self.map_rect.width, start_y),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Draw border
|
return surface
|
||||||
border_color = (80, 90, 70) if not is_night else (80, 85, 100)
|
|
||||||
|
def _draw_oil_field(self, oil_field: dict, is_night: bool) -> None:
|
||||||
|
"""Draw an oil field with pulsing glow effect."""
|
||||||
|
pos = oil_field.get("position", {"x": 0, "y": 0})
|
||||||
|
screen_x, screen_y = self.grid_to_screen(pos["x"], pos["y"])
|
||||||
|
|
||||||
|
cell_w, cell_h = self.get_cell_size()
|
||||||
|
radius = min(cell_w, cell_h) // 2 - 2
|
||||||
|
|
||||||
|
# Pulsing glow
|
||||||
|
pulse = 0.7 + 0.3 * math.sin(self.animation_tick * 0.05)
|
||||||
|
glow_color = tuple(int(c * pulse) for c in Colors.OIL_GLOW)
|
||||||
|
|
||||||
|
# Outer glow
|
||||||
|
for i in range(3, 0, -1):
|
||||||
|
alpha = int(30 * pulse / i)
|
||||||
|
glow_surface = pygame.Surface((radius * 4, radius * 4), pygame.SRCALPHA)
|
||||||
|
pygame.draw.circle(
|
||||||
|
glow_surface,
|
||||||
|
(*glow_color, alpha),
|
||||||
|
(radius * 2, radius * 2),
|
||||||
|
radius + i * 3,
|
||||||
|
)
|
||||||
|
self.screen.blit(
|
||||||
|
glow_surface,
|
||||||
|
(screen_x - radius * 2, screen_y - radius * 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Oil derrick shape
|
||||||
|
pygame.draw.circle(self.screen, Colors.OIL_FIELD, (screen_x, screen_y), radius)
|
||||||
|
pygame.draw.circle(self.screen, glow_color, (screen_x, screen_y), radius, 2)
|
||||||
|
|
||||||
|
# Derrick icon (triangle)
|
||||||
|
points = [
|
||||||
|
(screen_x, screen_y - radius + 2),
|
||||||
|
(screen_x - radius // 2, screen_y + radius // 2),
|
||||||
|
(screen_x + radius // 2, screen_y + radius // 2),
|
||||||
|
]
|
||||||
|
pygame.draw.polygon(self.screen, glow_color, points)
|
||||||
|
pygame.draw.polygon(self.screen, (40, 40, 40), points, 1)
|
||||||
|
|
||||||
|
# Oil remaining indicator
|
||||||
|
oil_remaining = oil_field.get("oil_remaining", 1000)
|
||||||
|
if oil_remaining < 500:
|
||||||
|
# Low oil warning
|
||||||
|
warning_pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||||
|
warning_color = (int(200 * warning_pulse), int(60 * warning_pulse), 0)
|
||||||
|
pygame.draw.circle(
|
||||||
|
self.screen, warning_color,
|
||||||
|
(screen_x + radius, screen_y - radius),
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_temple(self, temple: dict, is_night: bool) -> None:
|
||||||
|
"""Draw a temple with religion-colored glow."""
|
||||||
|
pos = temple.get("position", {"x": 0, "y": 0})
|
||||||
|
religion_type = temple.get("religion_type", "atheist")
|
||||||
|
screen_x, screen_y = self.grid_to_screen(pos["x"], pos["y"])
|
||||||
|
|
||||||
|
cell_w, cell_h = self.get_cell_size()
|
||||||
|
radius = min(cell_w, cell_h) // 2 - 2
|
||||||
|
|
||||||
|
# Get religion color
|
||||||
|
religion_color = Colors.RELIGIONS.get(religion_type, Colors.RELIGIONS["atheist"])
|
||||||
|
|
||||||
|
# Pulsing glow
|
||||||
|
pulse = 0.6 + 0.4 * math.sin(self.animation_tick * 0.03 + hash(religion_type) % 10)
|
||||||
|
glow_color = tuple(int(c * pulse) for c in religion_color)
|
||||||
|
|
||||||
|
# Outer divine glow
|
||||||
|
for i in range(4, 0, -1):
|
||||||
|
alpha = int(40 * pulse / i)
|
||||||
|
glow_surface = pygame.Surface((radius * 5, radius * 5), pygame.SRCALPHA)
|
||||||
|
pygame.draw.circle(
|
||||||
|
glow_surface,
|
||||||
|
(*glow_color, alpha),
|
||||||
|
(radius * 2.5, radius * 2.5),
|
||||||
|
int(radius + i * 4),
|
||||||
|
)
|
||||||
|
self.screen.blit(
|
||||||
|
glow_surface,
|
||||||
|
(screen_x - radius * 2.5, screen_y - radius * 2.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Temple base
|
||||||
|
pygame.draw.circle(self.screen, (40, 35, 50), (screen_x, screen_y), radius)
|
||||||
|
pygame.draw.circle(self.screen, glow_color, (screen_x, screen_y), radius, 2)
|
||||||
|
|
||||||
|
# Temple icon (cross/star pattern)
|
||||||
|
half = radius // 2
|
||||||
|
pygame.draw.line(self.screen, glow_color,
|
||||||
|
(screen_x, screen_y - half),
|
||||||
|
(screen_x, screen_y + half), 2)
|
||||||
|
pygame.draw.line(self.screen, glow_color,
|
||||||
|
(screen_x - half, screen_y),
|
||||||
|
(screen_x + half, screen_y), 2)
|
||||||
|
|
||||||
|
# Religion initial
|
||||||
|
font = pygame.font.Font(None, max(10, radius))
|
||||||
|
initial = religion_type[0].upper() if religion_type else "?"
|
||||||
|
text = font.render(initial, True, (255, 255, 255))
|
||||||
|
text_rect = text.get_rect(center=(screen_x, screen_y))
|
||||||
|
self.screen.blit(text, text_rect)
|
||||||
|
|
||||||
|
def draw(self, state: "SimulationState") -> None:
|
||||||
|
"""Draw the map background with all features."""
|
||||||
|
self.animation_tick += 1
|
||||||
|
|
||||||
|
is_night = state.time_of_day == "night"
|
||||||
|
|
||||||
|
# Draw terrain (cached for performance)
|
||||||
|
current_dims = (self.world_width, self.world_height,
|
||||||
|
self.map_rect.width, self.map_rect.height)
|
||||||
|
|
||||||
|
if self._terrain_surface is None or self._cached_dimensions != current_dims:
|
||||||
|
self._terrain_surface = self._render_terrain_surface(is_night)
|
||||||
|
self._cached_dimensions = current_dims
|
||||||
|
|
||||||
|
self.screen.blit(self._terrain_surface, self.map_rect.topleft)
|
||||||
|
|
||||||
|
# Draw oil fields
|
||||||
|
for oil_field in state.oil_fields:
|
||||||
|
self._draw_oil_field(oil_field, is_night)
|
||||||
|
|
||||||
|
# Draw temples
|
||||||
|
for temple in state.temples:
|
||||||
|
self._draw_temple(temple, is_night)
|
||||||
|
|
||||||
|
# Draw border with glow effect
|
||||||
|
border_color = (50, 55, 70) if not is_night else (35, 40, 55)
|
||||||
pygame.draw.rect(self.screen, border_color, self.map_rect, 2)
|
pygame.draw.rect(self.screen, border_color, self.map_rect, 2)
|
||||||
|
|
||||||
|
# Corner accents
|
||||||
|
corner_size = 15
|
||||||
|
accent_color = (80, 100, 130) if not is_night else (60, 75, 100)
|
||||||
|
corners = [
|
||||||
|
(self.map_rect.left, self.map_rect.top),
|
||||||
|
(self.map_rect.right - corner_size, self.map_rect.top),
|
||||||
|
(self.map_rect.left, self.map_rect.bottom - corner_size),
|
||||||
|
(self.map_rect.right - corner_size, self.map_rect.bottom - corner_size),
|
||||||
|
]
|
||||||
|
for cx, cy in corners:
|
||||||
|
pygame.draw.rect(self.screen, accent_color,
|
||||||
|
(cx, cy, corner_size, corner_size), 1)
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
"""Settings UI renderer with sliders for the Village Simulation."""
|
"""Settings UI renderer with sliders for the Village Simulation.
|
||||||
|
|
||||||
|
Includes settings for economy, religion, diplomacy, and oil.
|
||||||
|
"""
|
||||||
|
|
||||||
import pygame
|
import pygame
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@ -7,76 +10,132 @@ from typing import Optional, Callable, Any
|
|||||||
|
|
||||||
class Colors:
|
class Colors:
|
||||||
"""Color palette for settings UI."""
|
"""Color palette for settings UI."""
|
||||||
BG = (25, 28, 35)
|
BG = (15, 17, 23)
|
||||||
PANEL_BG = (35, 40, 50)
|
PANEL_BG = (22, 26, 35)
|
||||||
PANEL_BORDER = (70, 80, 95)
|
PANEL_HEADER = (28, 33, 45)
|
||||||
TEXT_PRIMARY = (230, 230, 235)
|
PANEL_BORDER = (50, 60, 80)
|
||||||
TEXT_SECONDARY = (160, 165, 175)
|
TEXT_PRIMARY = (225, 228, 235)
|
||||||
TEXT_HIGHLIGHT = (100, 180, 255)
|
TEXT_SECONDARY = (140, 150, 165)
|
||||||
SLIDER_BG = (50, 55, 65)
|
TEXT_HIGHLIGHT = (100, 200, 255)
|
||||||
SLIDER_FILL = (80, 140, 200)
|
SLIDER_BG = (40, 45, 55)
|
||||||
|
SLIDER_FILL = (70, 130, 200)
|
||||||
SLIDER_HANDLE = (220, 220, 230)
|
SLIDER_HANDLE = (220, 220, 230)
|
||||||
BUTTON_BG = (60, 100, 160)
|
BUTTON_BG = (50, 90, 150)
|
||||||
BUTTON_HOVER = (80, 120, 180)
|
BUTTON_HOVER = (70, 110, 170)
|
||||||
BUTTON_TEXT = (255, 255, 255)
|
BUTTON_TEXT = (255, 255, 255)
|
||||||
SUCCESS = (80, 180, 100)
|
SUCCESS = (80, 180, 100)
|
||||||
WARNING = (200, 160, 80)
|
WARNING = (200, 160, 80)
|
||||||
|
|
||||||
|
# Section colors
|
||||||
|
SECTION_ECONOMY = (100, 200, 255)
|
||||||
|
SECTION_WORLD = (100, 220, 150)
|
||||||
|
SECTION_RELIGION = (200, 150, 255)
|
||||||
|
SECTION_DIPLOMACY = (255, 180, 100)
|
||||||
|
SECTION_OIL = (180, 160, 100)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SliderConfig:
|
class SliderConfig:
|
||||||
"""Configuration for a slider widget."""
|
"""Configuration for a slider widget."""
|
||||||
name: str
|
name: str
|
||||||
key: str # Dot-separated path like "agent_stats.max_energy"
|
key: str
|
||||||
min_val: float
|
min_val: float
|
||||||
max_val: float
|
max_val: float
|
||||||
step: float = 1.0
|
step: float = 1.0
|
||||||
is_int: bool = True
|
is_int: bool = True
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
section: str = "General"
|
||||||
|
|
||||||
|
|
||||||
# Define all configurable parameters with sliders
|
# Organized slider configs by section
|
||||||
SLIDER_CONFIGS = [
|
SLIDER_CONFIGS = [
|
||||||
# Agent Stats Section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Max Energy", "agent_stats.max_energy", 50, 200, 10, True, "Maximum energy capacity"),
|
# WORLD SETTINGS
|
||||||
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Maximum hunger capacity"),
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Max Thirst", "agent_stats.max_thirst", 25, 100, 5, True, "Maximum thirst capacity"),
|
SliderConfig("World Width", "world.width", 15, 60, 5, True, "Grid width", "World"),
|
||||||
SliderConfig("Max Heat", "agent_stats.max_heat", 50, 200, 10, True, "Maximum heat capacity"),
|
SliderConfig("World Height", "world.height", 15, 60, 5, True, "Grid height", "World"),
|
||||||
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 10, 1, True, "Energy lost per turn"),
|
SliderConfig("Initial Agents", "world.initial_agents", 10, 200, 10, True, "Starting population", "World"),
|
||||||
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 10, 1, True, "Hunger lost per turn"),
|
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day", "World"),
|
||||||
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 10, 1, True, "Thirst lost per turn"),
|
SliderConfig("Starting Money", "world.starting_money", 50, 300, 25, True, "Initial coins", "World"),
|
||||||
SliderConfig("Heat Decay", "agent_stats.heat_decay", 1, 10, 1, True, "Heat lost per turn"),
|
SliderConfig("Inventory Slots", "world.inventory_slots", 6, 20, 2, True, "Max items", "World"),
|
||||||
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.5, 0.05, False, "Threshold for survival mode"),
|
|
||||||
|
|
||||||
# World Section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("World Width", "world.width", 10, 50, 5, True, "World grid width"),
|
# AGENT STATS
|
||||||
SliderConfig("World Height", "world.height", 10, 50, 5, True, "World grid height"),
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Initial Agents", "world.initial_agents", 2, 20, 1, True, "Starting agent count"),
|
SliderConfig("Max Energy", "agent_stats.max_energy", 30, 150, 10, True, "Energy capacity", "Stats"),
|
||||||
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day"),
|
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Hunger capacity", "Stats"),
|
||||||
SliderConfig("Inventory Slots", "world.inventory_slots", 5, 20, 1, True, "Agent inventory size"),
|
SliderConfig("Max Thirst", "agent_stats.max_thirst", 50, 200, 10, True, "Thirst capacity", "Stats"),
|
||||||
SliderConfig("Starting Money", "world.starting_money", 50, 500, 50, True, "Initial coins per agent"),
|
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 5, 1, True, "Energy lost/turn", "Stats"),
|
||||||
|
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 8, 1, True, "Hunger lost/turn", "Stats"),
|
||||||
|
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 8, 1, True, "Thirst lost/turn", "Stats"),
|
||||||
|
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.4, 0.05, False, "Survival mode threshold", "Stats"),
|
||||||
|
|
||||||
# Actions Section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Hunt Energy Cost", "actions.hunt_energy", -30, -5, 5, True, "Energy spent hunting"),
|
# ACTIONS
|
||||||
SliderConfig("Gather Energy Cost", "actions.gather_energy", -20, -1, 1, True, "Energy spent gathering"),
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Hunt Success %", "actions.hunt_success", 0.3, 1.0, 0.1, False, "Hunting success chance"),
|
SliderConfig("Hunt Energy", "actions.hunt_energy", -15, -3, 1, True, "Energy cost", "Actions"),
|
||||||
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 100, 10, True, "Energy restored by sleep"),
|
SliderConfig("Gather Energy", "actions.gather_energy", -8, -1, 1, True, "Energy cost", "Actions"),
|
||||||
SliderConfig("Rest Restore", "actions.rest_energy", 5, 30, 5, True, "Energy restored by rest"),
|
SliderConfig("Hunt Success %", "actions.hunt_success", 0.4, 1.0, 0.1, False, "Success chance", "Actions"),
|
||||||
|
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 80, 10, True, "Energy gained", "Actions"),
|
||||||
|
SliderConfig("Rest Restore", "actions.rest_energy", 5, 25, 5, True, "Energy gained", "Actions"),
|
||||||
|
|
||||||
# Resources Section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Meat Decay", "resources.meat_decay", 2, 20, 1, True, "Turns until meat spoils"),
|
# RELIGION
|
||||||
SliderConfig("Berries Decay", "resources.berries_decay", 10, 50, 5, True, "Turns until berries spoil"),
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Meat Hunger +", "resources.meat_hunger", 10, 60, 5, True, "Hunger restored by meat"),
|
SliderConfig("Max Faith", "agent_stats.max_faith", 50, 150, 10, True, "Faith capacity", "Religion"),
|
||||||
SliderConfig("Water Thirst +", "resources.water_thirst", 20, 60, 5, True, "Thirst restored by water"),
|
SliderConfig("Faith Decay", "agent_stats.faith_decay", 0, 5, 1, True, "Faith lost/turn", "Religion"),
|
||||||
|
SliderConfig("Pray Faith Gain", "actions.pray_faith_gain", 10, 50, 5, True, "Faith from prayer", "Religion"),
|
||||||
|
SliderConfig("Convert Chance", "actions.preach_convert_chance", 0.05, 0.4, 0.05, False, "Conversion rate", "Religion"),
|
||||||
|
SliderConfig("Zealot Threshold", "religion.zealot_threshold", 0.6, 0.95, 0.05, False, "Zealot faith %", "Religion"),
|
||||||
|
SliderConfig("Same Religion Bonus", "religion.same_religion_bonus", 0.0, 0.3, 0.05, False, "Trade bonus", "Religion"),
|
||||||
|
|
||||||
# Market Section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Discount Turns", "market.turns_before_discount", 1, 10, 1, True, "Turns before price drop"),
|
# DIPLOMACY
|
||||||
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.30, 0.05, False, "Price reduction per period"),
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
SliderConfig("Num Factions", "diplomacy.num_factions", 2, 8, 1, True, "Active factions", "Diplomacy"),
|
||||||
|
SliderConfig("Starting Relations", "diplomacy.starting_relations", 30, 70, 5, True, "Initial relation", "Diplomacy"),
|
||||||
|
SliderConfig("Alliance Threshold", "diplomacy.alliance_threshold", 60, 90, 5, True, "For alliance", "Diplomacy"),
|
||||||
|
SliderConfig("War Threshold", "diplomacy.war_threshold", 10, 40, 5, True, "For war", "Diplomacy"),
|
||||||
|
SliderConfig("Relation Decay", "diplomacy.relation_decay", 0, 5, 1, True, "Decay per turn", "Diplomacy"),
|
||||||
|
SliderConfig("War Exhaustion", "diplomacy.war_exhaustion_rate", 1, 10, 1, True, "Exhaustion/turn", "Diplomacy"),
|
||||||
|
SliderConfig("Peace Duration", "diplomacy.peace_treaty_duration", 10, 50, 5, True, "Treaty turns", "Diplomacy"),
|
||||||
|
|
||||||
# Simulation Section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Auto Step (s)", "auto_step_interval", 0.2, 3.0, 0.2, False, "Seconds between auto steps"),
|
# OIL & RESOURCES
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
SliderConfig("Oil Fields", "world.oil_fields_count", 1, 10, 1, True, "Number of fields", "Oil"),
|
||||||
|
SliderConfig("Drill Energy", "actions.drill_oil_energy", -20, -5, 1, True, "Drill cost", "Oil"),
|
||||||
|
SliderConfig("Drill Success %", "actions.drill_oil_success", 0.3, 1.0, 0.1, False, "Success chance", "Oil"),
|
||||||
|
SliderConfig("Oil Base Price", "economy.oil_base_price", 10, 50, 5, True, "Market price", "Oil"),
|
||||||
|
SliderConfig("Fuel Base Price", "economy.fuel_base_price", 20, 80, 5, True, "Market price", "Oil"),
|
||||||
|
SliderConfig("Fuel Heat", "resources.fuel_heat", 20, 60, 5, True, "Heat provided", "Oil"),
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# MARKET
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
SliderConfig("Discount Turns", "market.turns_before_discount", 5, 30, 5, True, "Before price drop", "Market"),
|
||||||
|
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.25, 0.05, False, "Per period", "Market"),
|
||||||
|
SliderConfig("Max Markup", "economy.max_price_markup", 1.5, 4.0, 0.5, False, "Price ceiling", "Market"),
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# SIMULATION
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
SliderConfig("Auto Step (s)", "auto_step_interval", 0.1, 2.0, 0.1, False, "Seconds/step", "Simulation"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Section order and colors
|
||||||
|
SECTION_ORDER = ["World", "Stats", "Actions", "Religion", "Diplomacy", "Oil", "Market", "Simulation"]
|
||||||
|
SECTION_COLORS = {
|
||||||
|
"World": Colors.SECTION_WORLD,
|
||||||
|
"Stats": Colors.SECTION_ECONOMY,
|
||||||
|
"Actions": Colors.SECTION_ECONOMY,
|
||||||
|
"Religion": Colors.SECTION_RELIGION,
|
||||||
|
"Diplomacy": Colors.SECTION_DIPLOMACY,
|
||||||
|
"Oil": Colors.SECTION_OIL,
|
||||||
|
"Market": Colors.SECTION_ECONOMY,
|
||||||
|
"Simulation": Colors.TEXT_SECONDARY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Slider:
|
class Slider:
|
||||||
"""A slider widget for adjusting numeric values."""
|
"""A slider widget for adjusting numeric values."""
|
||||||
@ -97,17 +156,17 @@ class Slider:
|
|||||||
self.hovered = False
|
self.hovered = False
|
||||||
|
|
||||||
def set_value(self, value: float) -> None:
|
def set_value(self, value: float) -> None:
|
||||||
"""Set the slider value."""
|
"""Set slider value."""
|
||||||
self.value = max(self.config.min_val, min(self.config.max_val, value))
|
self.value = max(self.config.min_val, min(self.config.max_val, value))
|
||||||
if self.config.is_int:
|
if self.config.is_int:
|
||||||
self.value = int(round(self.value))
|
self.value = int(round(self.value))
|
||||||
|
|
||||||
def get_value(self) -> Any:
|
def get_value(self) -> Any:
|
||||||
"""Get the current value."""
|
"""Get current value."""
|
||||||
return int(self.value) if self.config.is_int else round(self.value, 2)
|
return int(self.value) if self.config.is_int else round(self.value, 2)
|
||||||
|
|
||||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||||
"""Handle input events. Returns True if value changed."""
|
"""Handle events."""
|
||||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||||
if self._slider_area().collidepoint(event.pos):
|
if self._slider_area().collidepoint(event.pos):
|
||||||
self.dragging = True
|
self.dragging = True
|
||||||
@ -124,27 +183,23 @@ class Slider:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _slider_area(self) -> pygame.Rect:
|
def _slider_area(self) -> pygame.Rect:
|
||||||
"""Get the actual slider track area."""
|
"""Get slider track area."""
|
||||||
return pygame.Rect(
|
return pygame.Rect(
|
||||||
self.rect.x + 120, # Leave space for label
|
self.rect.x + 130,
|
||||||
self.rect.y + 15,
|
self.rect.y + 12,
|
||||||
self.rect.width - 180, # Leave space for value display
|
self.rect.width - 200,
|
||||||
20,
|
16,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _update_from_mouse(self, mouse_x: int) -> bool:
|
def _update_from_mouse(self, mouse_x: int) -> bool:
|
||||||
"""Update value based on mouse position."""
|
"""Update value from mouse."""
|
||||||
slider_area = self._slider_area()
|
slider_area = self._slider_area()
|
||||||
|
|
||||||
# Calculate position as 0-1
|
|
||||||
rel_x = mouse_x - slider_area.x
|
rel_x = mouse_x - slider_area.x
|
||||||
ratio = max(0, min(1, rel_x / slider_area.width))
|
ratio = max(0, min(1, rel_x / slider_area.width))
|
||||||
|
|
||||||
# Calculate value
|
|
||||||
range_val = self.config.max_val - self.config.min_val
|
range_val = self.config.max_val - self.config.min_val
|
||||||
new_value = self.config.min_val + ratio * range_val
|
new_value = self.config.min_val + ratio * range_val
|
||||||
|
|
||||||
# Apply step
|
|
||||||
if self.config.step > 0:
|
if self.config.step > 0:
|
||||||
new_value = round(new_value / self.config.step) * self.config.step
|
new_value = round(new_value / self.config.step) * self.config.step
|
||||||
|
|
||||||
@ -152,45 +207,44 @@ class Slider:
|
|||||||
self.set_value(new_value)
|
self.set_value(new_value)
|
||||||
return abs(old_value - self.value) > 0.001
|
return abs(old_value - self.value) > 0.001
|
||||||
|
|
||||||
def draw(self, screen: pygame.Surface) -> None:
|
def draw(self, screen: pygame.Surface, section_color: tuple) -> None:
|
||||||
"""Draw the slider."""
|
"""Draw the slider."""
|
||||||
# Background
|
# Hover highlight
|
||||||
if self.hovered:
|
if self.hovered:
|
||||||
pygame.draw.rect(screen, (45, 50, 60), self.rect)
|
pygame.draw.rect(screen, (35, 40, 50), self.rect, border_radius=4)
|
||||||
|
|
||||||
# Label
|
# Label
|
||||||
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
|
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
|
||||||
screen.blit(label, (self.rect.x + 5, self.rect.y + 5))
|
screen.blit(label, (self.rect.x + 8, self.rect.y + 6))
|
||||||
|
|
||||||
# Slider track
|
# Slider track
|
||||||
slider_area = self._slider_area()
|
slider_area = self._slider_area()
|
||||||
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=3)
|
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=4)
|
||||||
|
|
||||||
# Slider fill
|
# Slider fill
|
||||||
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
|
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
|
||||||
fill_width = int(ratio * slider_area.width)
|
fill_width = int(ratio * slider_area.width)
|
||||||
fill_rect = pygame.Rect(slider_area.x, slider_area.y, fill_width, slider_area.height)
|
fill_rect = pygame.Rect(slider_area.x, slider_area.y, fill_width, slider_area.height)
|
||||||
pygame.draw.rect(screen, Colors.SLIDER_FILL, fill_rect, border_radius=3)
|
pygame.draw.rect(screen, section_color, fill_rect, border_radius=4)
|
||||||
|
|
||||||
# Handle
|
# Handle
|
||||||
handle_x = slider_area.x + fill_width
|
handle_x = slider_area.x + fill_width
|
||||||
handle_rect = pygame.Rect(handle_x - 4, slider_area.y - 2, 8, slider_area.height + 4)
|
handle_rect = pygame.Rect(handle_x - 5, slider_area.y - 2, 10, slider_area.height + 4)
|
||||||
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=2)
|
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=3)
|
||||||
|
|
||||||
# Value display
|
# Value display
|
||||||
value_str = str(self.get_value())
|
value_str = str(self.get_value())
|
||||||
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
|
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
|
||||||
value_x = self.rect.right - 50
|
screen.blit(value_text, (self.rect.right - 55, self.rect.y + 6))
|
||||||
screen.blit(value_text, (value_x, self.rect.y + 5))
|
|
||||||
|
|
||||||
# Description on hover
|
# Description on hover
|
||||||
if self.hovered and self.config.description:
|
if self.hovered and self.config.description:
|
||||||
desc = self.small_font.render(self.config.description, True, Colors.TEXT_SECONDARY)
|
desc = self.small_font.render(self.config.description, True, Colors.TEXT_SECONDARY)
|
||||||
screen.blit(desc, (self.rect.x + 5, self.rect.y + 25))
|
screen.blit(desc, (self.rect.x + 8, self.rect.y + 24))
|
||||||
|
|
||||||
|
|
||||||
class Button:
|
class Button:
|
||||||
"""A simple button widget."""
|
"""Button widget."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -208,7 +262,7 @@ class Button:
|
|||||||
self.hovered = False
|
self.hovered = False
|
||||||
|
|
||||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||||
"""Handle input events. Returns True if clicked."""
|
"""Handle events."""
|
||||||
if event.type == pygame.MOUSEMOTION:
|
if event.type == pygame.MOUSEMOTION:
|
||||||
self.hovered = self.rect.collidepoint(event.pos)
|
self.hovered = self.rect.collidepoint(event.pos)
|
||||||
|
|
||||||
@ -221,10 +275,10 @@ class Button:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def draw(self, screen: pygame.Surface) -> None:
|
def draw(self, screen: pygame.Surface) -> None:
|
||||||
"""Draw the button."""
|
"""Draw button."""
|
||||||
color = Colors.BUTTON_HOVER if self.hovered else self.color
|
color = Colors.BUTTON_HOVER if self.hovered else self.color
|
||||||
pygame.draw.rect(screen, color, self.rect, border_radius=5)
|
pygame.draw.rect(screen, color, self.rect, border_radius=6)
|
||||||
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=5)
|
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=6)
|
||||||
|
|
||||||
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
|
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
|
||||||
text_rect = text.get_rect(center=self.rect.center)
|
text_rect = text.get_rect(center=self.rect.center)
|
||||||
@ -232,21 +286,23 @@ class Button:
|
|||||||
|
|
||||||
|
|
||||||
class SettingsRenderer:
|
class SettingsRenderer:
|
||||||
"""Renders the settings UI panel with sliders."""
|
"""Settings panel with organized sections and sliders."""
|
||||||
|
|
||||||
def __init__(self, screen: pygame.Surface):
|
def __init__(self, screen: pygame.Surface):
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.font = pygame.font.Font(None, 24)
|
self.font = pygame.font.Font(None, 22)
|
||||||
self.small_font = pygame.font.Font(None, 18)
|
self.small_font = pygame.font.Font(None, 16)
|
||||||
self.title_font = pygame.font.Font(None, 32)
|
self.title_font = pygame.font.Font(None, 28)
|
||||||
|
self.section_font = pygame.font.Font(None, 20)
|
||||||
|
|
||||||
self.visible = False
|
self.visible = False
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
self.max_scroll = 0
|
self.max_scroll = 0
|
||||||
|
self.current_section = 0
|
||||||
|
|
||||||
# Create sliders
|
|
||||||
self.sliders: list[Slider] = []
|
self.sliders: list[Slider] = []
|
||||||
self.buttons: list[Button] = []
|
self.buttons: list[Button] = []
|
||||||
|
self.section_tabs: list[pygame.Rect] = []
|
||||||
self.config_data: dict = {}
|
self.config_data: dict = {}
|
||||||
|
|
||||||
self._create_widgets()
|
self._create_widgets()
|
||||||
@ -254,32 +310,44 @@ class SettingsRenderer:
|
|||||||
self.status_color = Colors.TEXT_SECONDARY
|
self.status_color = Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
def _create_widgets(self) -> None:
|
def _create_widgets(self) -> None:
|
||||||
"""Create slider widgets."""
|
"""Create widgets."""
|
||||||
panel_width = 400
|
screen_w, screen_h = self.screen.get_size()
|
||||||
slider_height = 45
|
|
||||||
start_y = 80
|
|
||||||
|
|
||||||
panel_x = (self.screen.get_width() - panel_width) // 2
|
# Panel dimensions - wider for better readability
|
||||||
|
panel_width = min(600, screen_w - 100)
|
||||||
|
panel_height = screen_h - 80
|
||||||
|
panel_x = (screen_w - panel_width) // 2
|
||||||
|
panel_y = 40
|
||||||
|
|
||||||
for i, config in enumerate(SLIDER_CONFIGS):
|
self.panel_rect = pygame.Rect(panel_x, panel_y, panel_width, panel_height)
|
||||||
rect = pygame.Rect(
|
|
||||||
panel_x + 10,
|
# Tab bar for sections
|
||||||
start_y + i * slider_height,
|
tab_height = 30
|
||||||
panel_width - 20,
|
self.tab_rect = pygame.Rect(panel_x, panel_y, panel_width, tab_height)
|
||||||
slider_height,
|
|
||||||
)
|
# Content area
|
||||||
|
content_start_y = panel_y + tab_height + 10
|
||||||
|
slider_height = 38
|
||||||
|
|
||||||
|
# Group sliders by section
|
||||||
|
self.sliders_by_section: dict[str, list[Slider]] = {s: [] for s in SECTION_ORDER}
|
||||||
|
|
||||||
|
slider_width = panel_width - 40
|
||||||
|
|
||||||
|
for config in SLIDER_CONFIGS:
|
||||||
|
rect = pygame.Rect(panel_x + 20, 0, slider_width, slider_height)
|
||||||
slider = Slider(rect, config, self.font, self.small_font)
|
slider = Slider(rect, config, self.font, self.small_font)
|
||||||
self.sliders.append(slider)
|
self.sliders.append(slider)
|
||||||
|
self.sliders_by_section[config.section].append(slider)
|
||||||
|
|
||||||
# Calculate max scroll
|
# Calculate positions for current section
|
||||||
total_height = len(SLIDER_CONFIGS) * slider_height + 150
|
self._layout_current_section()
|
||||||
visible_height = self.screen.get_height() - 150
|
|
||||||
self.max_scroll = max(0, total_height - visible_height)
|
|
||||||
|
|
||||||
# Create buttons at the bottom
|
# Buttons at bottom
|
||||||
button_y = self.screen.get_height() - 60
|
button_y = panel_y + panel_height - 50
|
||||||
button_width = 100
|
button_width = 120
|
||||||
button_height = 35
|
button_height = 35
|
||||||
|
button_spacing = 15
|
||||||
|
|
||||||
buttons_data = [
|
buttons_data = [
|
||||||
("Apply & Restart", self._apply_config, Colors.SUCCESS),
|
("Apply & Restart", self._apply_config, Colors.SUCCESS),
|
||||||
@ -287,26 +355,43 @@ class SettingsRenderer:
|
|||||||
("Close", self.toggle, Colors.PANEL_BORDER),
|
("Close", self.toggle, Colors.PANEL_BORDER),
|
||||||
]
|
]
|
||||||
|
|
||||||
total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10
|
total_w = len(buttons_data) * button_width + (len(buttons_data) - 1) * button_spacing
|
||||||
start_x = (self.screen.get_width() - total_button_width) // 2
|
start_x = panel_x + (panel_width - total_w) // 2
|
||||||
|
|
||||||
for i, (text, callback, color) in enumerate(buttons_data):
|
for i, (text, callback, color) in enumerate(buttons_data):
|
||||||
rect = pygame.Rect(
|
rect = pygame.Rect(
|
||||||
start_x + i * (button_width + 10),
|
start_x + i * (button_width + button_spacing),
|
||||||
button_y,
|
button_y,
|
||||||
button_width,
|
button_width,
|
||||||
button_height,
|
button_height,
|
||||||
)
|
)
|
||||||
self.buttons.append(Button(rect, text, self.small_font, callback, color))
|
self.buttons.append(Button(rect, text, self.small_font, callback, color))
|
||||||
|
|
||||||
|
def _layout_current_section(self) -> None:
|
||||||
|
"""Layout sliders for current section."""
|
||||||
|
section = SECTION_ORDER[self.current_section]
|
||||||
|
sliders = self.sliders_by_section[section]
|
||||||
|
|
||||||
|
content_y = self.panel_rect.y + 50
|
||||||
|
slider_height = 38
|
||||||
|
|
||||||
|
for i, slider in enumerate(sliders):
|
||||||
|
slider.rect.y = content_y + i * slider_height - self.scroll_offset
|
||||||
|
|
||||||
|
# Calculate max scroll
|
||||||
|
total_height = len(sliders) * slider_height
|
||||||
|
visible_height = self.panel_rect.height - 120
|
||||||
|
self.max_scroll = max(0, total_height - visible_height)
|
||||||
|
|
||||||
def toggle(self) -> None:
|
def toggle(self) -> None:
|
||||||
"""Toggle settings visibility."""
|
"""Toggle visibility."""
|
||||||
self.visible = not self.visible
|
self.visible = not self.visible
|
||||||
if self.visible:
|
if self.visible:
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
|
self._layout_current_section()
|
||||||
|
|
||||||
def set_config(self, config_data: dict) -> None:
|
def set_config(self, config_data: dict) -> None:
|
||||||
"""Set slider values from config data."""
|
"""Set slider values from config."""
|
||||||
self.config_data = config_data
|
self.config_data = config_data
|
||||||
|
|
||||||
for slider in self.sliders:
|
for slider in self.sliders:
|
||||||
@ -315,16 +400,14 @@ class SettingsRenderer:
|
|||||||
slider.set_value(value)
|
slider.set_value(value)
|
||||||
|
|
||||||
def get_config(self) -> dict:
|
def get_config(self) -> dict:
|
||||||
"""Get current config from slider values."""
|
"""Get config from sliders."""
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
for slider in self.sliders:
|
for slider in self.sliders:
|
||||||
self._set_nested_value(result, slider.config.key, slider.get_value())
|
self._set_nested_value(result, slider.config.key, slider.get_value())
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_nested_value(self, data: dict, key: str) -> Any:
|
def _get_nested_value(self, data: dict, key: str) -> Any:
|
||||||
"""Get a value from nested dict using dot notation."""
|
"""Get nested dict value."""
|
||||||
parts = key.split(".")
|
parts = key.split(".")
|
||||||
current = data
|
current = data
|
||||||
for part in parts:
|
for part in parts:
|
||||||
@ -335,7 +418,7 @@ class SettingsRenderer:
|
|||||||
return current
|
return current
|
||||||
|
|
||||||
def _set_nested_value(self, data: dict, key: str, value: Any) -> None:
|
def _set_nested_value(self, data: dict, key: str, value: Any) -> None:
|
||||||
"""Set a value in nested dict using dot notation."""
|
"""Set nested dict value."""
|
||||||
parts = key.split(".")
|
parts = key.split(".")
|
||||||
current = data
|
current = data
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
@ -345,104 +428,138 @@ class SettingsRenderer:
|
|||||||
current[parts[-1]] = value
|
current[parts[-1]] = value
|
||||||
|
|
||||||
def _apply_config(self) -> None:
|
def _apply_config(self) -> None:
|
||||||
"""Apply configuration callback (to be set externally)."""
|
"""Apply config callback."""
|
||||||
self.status_message = "Config applied - restart to see changes"
|
self.status_message = "Config applied - restart to see changes"
|
||||||
self.status_color = Colors.SUCCESS
|
self.status_color = Colors.SUCCESS
|
||||||
|
|
||||||
def _reset_config(self) -> None:
|
def _reset_config(self) -> None:
|
||||||
"""Reset configuration callback (to be set externally)."""
|
"""Reset config callback."""
|
||||||
self.status_message = "Config reset to defaults"
|
self.status_message = "Config reset to defaults"
|
||||||
self.status_color = Colors.WARNING
|
self.status_color = Colors.WARNING
|
||||||
|
|
||||||
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 events."""
|
||||||
if not self.visible:
|
if not self.visible:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Handle scrolling
|
# Tab clicks
|
||||||
|
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||||
|
if self.tab_rect.collidepoint(event.pos):
|
||||||
|
tab_width = self.panel_rect.width // len(SECTION_ORDER)
|
||||||
|
rel_x = event.pos[0] - self.tab_rect.x
|
||||||
|
tab_idx = rel_x // tab_width
|
||||||
|
if 0 <= tab_idx < len(SECTION_ORDER) and tab_idx != self.current_section:
|
||||||
|
self.current_section = tab_idx
|
||||||
|
self.scroll_offset = 0
|
||||||
|
self._layout_current_section()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Scrolling
|
||||||
if event.type == pygame.MOUSEWHEEL:
|
if event.type == pygame.MOUSEWHEEL:
|
||||||
self.scroll_offset -= event.y * 30
|
self.scroll_offset -= event.y * 30
|
||||||
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
|
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
|
||||||
|
self._layout_current_section()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Handle sliders
|
# Sliders for current section
|
||||||
for slider in self.sliders:
|
section = SECTION_ORDER[self.current_section]
|
||||||
# Adjust slider position for scroll
|
for slider in self.sliders_by_section[section]:
|
||||||
original_y = slider.rect.y
|
adjusted_rect = slider.rect.copy()
|
||||||
slider.rect.y -= self.scroll_offset
|
|
||||||
|
|
||||||
if slider.handle_event(event):
|
if slider.handle_event(event):
|
||||||
slider.rect.y = original_y
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
slider.rect.y = original_y
|
# Buttons
|
||||||
|
|
||||||
# Handle buttons
|
|
||||||
for button in self.buttons:
|
for button in self.buttons:
|
||||||
if button.handle_event(event):
|
if button.handle_event(event):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Consume all clicks when settings are visible
|
# Consume clicks
|
||||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def draw(self) -> None:
|
def draw(self) -> None:
|
||||||
"""Draw the settings panel."""
|
"""Draw settings 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, 200))
|
overlay.fill((0, 0, 0, 220))
|
||||||
self.screen.blit(overlay, (0, 0))
|
self.screen.blit(overlay, (0, 0))
|
||||||
|
|
||||||
# Panel background
|
# Panel
|
||||||
panel_width = 420
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, self.panel_rect, border_radius=10)
|
||||||
panel_height = self.screen.get_height() - 40
|
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, self.panel_rect, 2, border_radius=10)
|
||||||
panel_x = (self.screen.get_width() - panel_width) // 2
|
|
||||||
panel_rect = pygame.Rect(panel_x, 20, panel_width, panel_height)
|
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, panel_rect, border_radius=10)
|
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, panel_rect, 2, border_radius=10)
|
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY)
|
title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY)
|
||||||
title_rect = title.get_rect(centerx=self.screen.get_width() // 2, y=35)
|
title_rect = title.get_rect(centerx=self.panel_rect.centerx, y=self.panel_rect.y + 8)
|
||||||
self.screen.blit(title, title_rect)
|
self.screen.blit(title, title_rect)
|
||||||
|
|
||||||
# Create clipping region for scrollable area
|
# Section tabs
|
||||||
clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130)
|
self._draw_section_tabs()
|
||||||
|
|
||||||
# Draw sliders with scroll offset
|
# Clipping for sliders
|
||||||
for slider in self.sliders:
|
clip_rect = pygame.Rect(
|
||||||
# Adjust position for scroll
|
self.panel_rect.x + 10,
|
||||||
adjusted_rect = slider.rect.copy()
|
self.panel_rect.y + 45,
|
||||||
adjusted_rect.y -= self.scroll_offset
|
self.panel_rect.width - 20,
|
||||||
|
self.panel_rect.height - 110,
|
||||||
|
)
|
||||||
|
|
||||||
# Only draw if visible
|
# Draw sliders for current section
|
||||||
if clip_rect.colliderect(adjusted_rect):
|
section = SECTION_ORDER[self.current_section]
|
||||||
# Temporarily move slider for drawing
|
section_color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
|
||||||
original_y = slider.rect.y
|
|
||||||
slider.rect.y = adjusted_rect.y
|
|
||||||
slider.draw(self.screen)
|
|
||||||
slider.rect.y = original_y
|
|
||||||
|
|
||||||
# Draw scroll indicator
|
for slider in self.sliders_by_section[section]:
|
||||||
|
if clip_rect.colliderect(slider.rect):
|
||||||
|
slider.draw(self.screen, section_color)
|
||||||
|
|
||||||
|
# Scroll indicator
|
||||||
if self.max_scroll > 0:
|
if self.max_scroll > 0:
|
||||||
scroll_ratio = self.scroll_offset / self.max_scroll
|
scroll_ratio = self.scroll_offset / self.max_scroll
|
||||||
scroll_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
|
bar_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
|
||||||
scroll_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - scroll_height))
|
bar_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - bar_height))
|
||||||
scroll_rect = pygame.Rect(panel_rect.right - 8, scroll_y, 4, scroll_height)
|
bar_rect = pygame.Rect(self.panel_rect.right - 12, bar_y, 5, bar_height)
|
||||||
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, scroll_rect, border_radius=2)
|
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, bar_rect, border_radius=2)
|
||||||
|
|
||||||
# Draw buttons
|
# Buttons
|
||||||
for button in self.buttons:
|
for button in self.buttons:
|
||||||
button.draw(self.screen)
|
button.draw(self.screen)
|
||||||
|
|
||||||
# Status message
|
# Status message
|
||||||
if self.status_message:
|
if self.status_message:
|
||||||
status = self.small_font.render(self.status_message, True, self.status_color)
|
status = self.small_font.render(self.status_message, True, self.status_color)
|
||||||
status_rect = status.get_rect(centerx=self.screen.get_width() // 2, y=self.screen.get_height() - 90)
|
status_rect = status.get_rect(
|
||||||
|
centerx=self.panel_rect.centerx,
|
||||||
|
y=self.panel_rect.bottom - 80
|
||||||
|
)
|
||||||
self.screen.blit(status, status_rect)
|
self.screen.blit(status, status_rect)
|
||||||
|
|
||||||
|
def _draw_section_tabs(self) -> None:
|
||||||
|
"""Draw section tabs."""
|
||||||
|
tab_width = self.panel_rect.width // len(SECTION_ORDER)
|
||||||
|
tab_y = self.panel_rect.y + 32
|
||||||
|
tab_height = 20
|
||||||
|
|
||||||
|
for i, section in enumerate(SECTION_ORDER):
|
||||||
|
tab_x = self.panel_rect.x + i * tab_width
|
||||||
|
tab_rect = pygame.Rect(tab_x, tab_y, tab_width, tab_height)
|
||||||
|
|
||||||
|
color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
|
||||||
|
|
||||||
|
if i == self.current_section:
|
||||||
|
pygame.draw.rect(self.screen, color, tab_rect, border_radius=3)
|
||||||
|
text_color = Colors.BG
|
||||||
|
else:
|
||||||
|
pygame.draw.rect(self.screen, (40, 45, 55), tab_rect, border_radius=3)
|
||||||
|
text_color = color
|
||||||
|
|
||||||
|
# Section name (abbreviated)
|
||||||
|
name = section[:5] if len(section) > 5 else section
|
||||||
|
text = self.small_font.render(name, True, text_color)
|
||||||
|
text_rect = text.get_rect(center=tab_rect.center)
|
||||||
|
self.screen.blit(text, text_rect)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,217 +1,591 @@
|
|||||||
"""UI renderer for the Village Simulation."""
|
"""UI renderer for the Village Simulation.
|
||||||
|
|
||||||
|
Beautiful dark theme with panels for statistics, factions, religion, and diplomacy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
import pygame
|
import pygame
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from frontend.client import SimulationState
|
from frontend.client import SimulationState
|
||||||
|
|
||||||
|
|
||||||
class Colors:
|
class Colors:
|
||||||
# UI colors
|
# Base UI colors - dark cyberpunk theme
|
||||||
PANEL_BG = (35, 40, 50)
|
BG = (15, 17, 23)
|
||||||
PANEL_BORDER = (70, 80, 95)
|
PANEL_BG = (22, 26, 35)
|
||||||
TEXT_PRIMARY = (230, 230, 235)
|
PANEL_HEADER = (28, 33, 45)
|
||||||
TEXT_SECONDARY = (160, 165, 175)
|
PANEL_BORDER = (45, 55, 70)
|
||||||
TEXT_HIGHLIGHT = (100, 180, 255)
|
PANEL_ACCENT = (60, 80, 110)
|
||||||
|
|
||||||
|
# Text
|
||||||
|
TEXT_PRIMARY = (225, 228, 235)
|
||||||
|
TEXT_SECONDARY = (140, 150, 165)
|
||||||
|
TEXT_HIGHLIGHT = (100, 200, 255)
|
||||||
TEXT_WARNING = (255, 180, 80)
|
TEXT_WARNING = (255, 180, 80)
|
||||||
TEXT_DANGER = (255, 100, 100)
|
TEXT_DANGER = (255, 100, 100)
|
||||||
|
TEXT_SUCCESS = (100, 220, 140)
|
||||||
|
|
||||||
# Day/Night indicator
|
# Day/Night
|
||||||
DAY_COLOR = (255, 220, 100)
|
DAY_COLOR = (255, 220, 100)
|
||||||
NIGHT_COLOR = (100, 120, 180)
|
NIGHT_COLOR = (100, 140, 200)
|
||||||
|
|
||||||
|
# Faction colors
|
||||||
|
FACTIONS = {
|
||||||
|
"northlands": (100, 160, 220),
|
||||||
|
"riverfolk": (70, 160, 180),
|
||||||
|
"forestkin": (90, 160, 80),
|
||||||
|
"mountaineer": (150, 120, 90),
|
||||||
|
"plainsmen": (200, 180, 100),
|
||||||
|
"neutral": (120, 120, 120),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Religion colors
|
||||||
|
RELIGIONS = {
|
||||||
|
"solaris": (255, 200, 80),
|
||||||
|
"aquarius": (80, 170, 240),
|
||||||
|
"terranus": (160, 120, 70),
|
||||||
|
"ignis": (240, 100, 50),
|
||||||
|
"naturis": (100, 200, 100),
|
||||||
|
"atheist": (140, 140, 140),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scrollbar
|
||||||
|
SCROLLBAR_BG = (35, 40, 50)
|
||||||
|
SCROLLBAR_HANDLE = (70, 90, 120)
|
||||||
|
|
||||||
|
|
||||||
class UIRenderer:
|
class UIRenderer:
|
||||||
"""Renders UI elements (HUD, panels, text info)."""
|
"""Renders UI elements (HUD, panels, text info)."""
|
||||||
|
|
||||||
def __init__(self, screen: pygame.Surface, font: pygame.font.Font):
|
def __init__(
|
||||||
|
self,
|
||||||
|
screen: pygame.Surface,
|
||||||
|
font: pygame.font.Font,
|
||||||
|
top_panel_height: int = 50,
|
||||||
|
right_panel_width: int = 280,
|
||||||
|
bottom_panel_height: int = 60,
|
||||||
|
):
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.font = font
|
self.font = font
|
||||||
self.small_font = pygame.font.Font(None, 20)
|
self.top_panel_height = top_panel_height
|
||||||
self.title_font = pygame.font.Font(None, 28)
|
self.right_panel_width = right_panel_width
|
||||||
|
self.bottom_panel_height = bottom_panel_height
|
||||||
|
|
||||||
# Panel dimensions
|
# Fonts
|
||||||
self.top_panel_height = 50
|
self.small_font = pygame.font.Font(None, 16)
|
||||||
self.right_panel_width = 200
|
self.medium_font = pygame.font.Font(None, 20)
|
||||||
|
self.title_font = pygame.font.Font(None, 24)
|
||||||
|
self.large_font = pygame.font.Font(None, 28)
|
||||||
|
|
||||||
def _draw_panel(self, rect: pygame.Rect, title: Optional[str] = None) -> None:
|
# Scrolling state for right panel
|
||||||
"""Draw a panel background."""
|
self.scroll_offset = 0
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
self.max_scroll = 0
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1)
|
self.scroll_dragging = False
|
||||||
|
|
||||||
|
# Animation
|
||||||
|
self.animation_tick = 0
|
||||||
|
|
||||||
|
def _draw_panel_bg(
|
||||||
|
self,
|
||||||
|
rect: pygame.Rect,
|
||||||
|
title: str = None,
|
||||||
|
accent_color: tuple = None,
|
||||||
|
) -> int:
|
||||||
|
"""Draw a panel background. Returns Y position after header."""
|
||||||
|
# Main background
|
||||||
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect, border_radius=4)
|
||||||
|
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1, border_radius=4)
|
||||||
|
|
||||||
|
y = rect.y + 6
|
||||||
|
|
||||||
if title:
|
if title:
|
||||||
title_text = self.small_font.render(title, True, Colors.TEXT_SECONDARY)
|
# Header area
|
||||||
self.screen.blit(title_text, (rect.x + 8, rect.y + 4))
|
header_rect = pygame.Rect(rect.x, rect.y, rect.width, 24)
|
||||||
|
pygame.draw.rect(
|
||||||
|
self.screen, Colors.PANEL_HEADER, header_rect,
|
||||||
|
border_top_left_radius=4, border_top_right_radius=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accent line
|
||||||
|
if accent_color:
|
||||||
|
pygame.draw.line(
|
||||||
|
self.screen, accent_color,
|
||||||
|
(rect.x + 2, rect.y + 24),
|
||||||
|
(rect.x + rect.width - 2, rect.y + 24),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
text = self.medium_font.render(title, True, Colors.TEXT_PRIMARY)
|
||||||
|
self.screen.blit(text, (rect.x + 10, rect.y + 5))
|
||||||
|
y = rect.y + 30
|
||||||
|
|
||||||
|
return y
|
||||||
|
|
||||||
|
def _draw_progress_bar(
|
||||||
|
self,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
value: float,
|
||||||
|
max_value: float,
|
||||||
|
color: tuple,
|
||||||
|
bg_color: tuple = (35, 40, 50),
|
||||||
|
show_label: bool = False,
|
||||||
|
label: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Draw a styled progress bar."""
|
||||||
|
# Background
|
||||||
|
pygame.draw.rect(self.screen, bg_color, (x, y, width, height), border_radius=2)
|
||||||
|
|
||||||
|
# Fill
|
||||||
|
if max_value > 0:
|
||||||
|
ratio = min(1.0, value / max_value)
|
||||||
|
fill_width = int(ratio * width)
|
||||||
|
if fill_width > 0:
|
||||||
|
pygame.draw.rect(
|
||||||
|
self.screen, color,
|
||||||
|
(x, y, fill_width, height),
|
||||||
|
border_radius=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Label
|
||||||
|
if show_label and label:
|
||||||
|
text = self.small_font.render(label, True, Colors.TEXT_PRIMARY)
|
||||||
|
text_rect = text.get_rect(midleft=(x + 4, y + height // 2))
|
||||||
|
self.screen.blit(text, text_rect)
|
||||||
|
|
||||||
def draw_top_bar(self, state: "SimulationState") -> None:
|
def draw_top_bar(self, state: "SimulationState") -> None:
|
||||||
"""Draw the top information bar."""
|
"""Draw the top information bar."""
|
||||||
|
self.animation_tick += 1
|
||||||
|
|
||||||
rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height)
|
rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height)
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||||
pygame.draw.line(
|
pygame.draw.line(
|
||||||
self.screen,
|
self.screen, Colors.PANEL_BORDER,
|
||||||
Colors.PANEL_BORDER,
|
(0, self.top_panel_height - 1),
|
||||||
(0, self.top_panel_height),
|
(self.screen.get_width(), self.top_panel_height - 1),
|
||||||
(self.screen.get_width(), self.top_panel_height),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Day/Night and Turn info
|
# Day/Night indicator with animated glow
|
||||||
is_night = state.time_of_day == "night"
|
is_night = state.time_of_day == "night"
|
||||||
time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR
|
time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR
|
||||||
time_text = "NIGHT" if is_night else "DAY"
|
time_text = "NIGHT" if is_night else "DAY"
|
||||||
|
|
||||||
# Draw time indicator circle
|
# Glow effect
|
||||||
pygame.draw.circle(self.screen, time_color, (25, 25), 12)
|
glow_alpha = int(100 + 50 * math.sin(self.animation_tick * 0.05))
|
||||||
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (25, 25), 12, 1)
|
glow_surface = pygame.Surface((40, 40), pygame.SRCALPHA)
|
||||||
|
pygame.draw.circle(glow_surface, (*time_color, glow_alpha), (20, 20), 18)
|
||||||
|
self.screen.blit(glow_surface, (10, 5))
|
||||||
|
|
||||||
# Time/day text
|
# Time circle
|
||||||
|
pygame.draw.circle(self.screen, time_color, (30, 25), 12)
|
||||||
|
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (30, 25), 12, 1)
|
||||||
|
|
||||||
|
# Time/turn info
|
||||||
info_text = f"{time_text} | Day {state.day}, Step {state.step_in_day} | Turn {state.turn}"
|
info_text = f"{time_text} | Day {state.day}, Step {state.step_in_day} | Turn {state.turn}"
|
||||||
text = self.font.render(info_text, True, Colors.TEXT_PRIMARY)
|
text = self.font.render(info_text, True, Colors.TEXT_PRIMARY)
|
||||||
self.screen.blit(text, (50, 15))
|
self.screen.blit(text, (55, 14))
|
||||||
|
|
||||||
|
# Agent count
|
||||||
|
living = len(state.get_living_agents())
|
||||||
|
total = len(state.agents)
|
||||||
|
agent_text = f"Population: {living}/{total}"
|
||||||
|
color = Colors.TEXT_SUCCESS if living > total * 0.5 else Colors.TEXT_WARNING
|
||||||
|
if living < total * 0.25:
|
||||||
|
color = Colors.TEXT_DANGER
|
||||||
|
text = self.medium_font.render(agent_text, True, color)
|
||||||
|
self.screen.blit(text, (300, 16))
|
||||||
|
|
||||||
|
# Active wars indicator
|
||||||
|
active_wars = len(state.active_wars)
|
||||||
|
if active_wars > 0:
|
||||||
|
war_pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||||
|
war_color = (int(200 * war_pulse), int(60 * war_pulse), int(60 * war_pulse))
|
||||||
|
war_text = f"⚔ {active_wars} WAR{'S' if active_wars > 1 else ''}"
|
||||||
|
text = self.medium_font.render(war_text, True, war_color)
|
||||||
|
self.screen.blit(text, (450, 16))
|
||||||
|
|
||||||
|
# Mode and status (right side)
|
||||||
|
right_x = self.screen.get_width() - 180
|
||||||
|
|
||||||
# Mode indicator
|
|
||||||
mode_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY
|
mode_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY
|
||||||
mode_text = f"Mode: {state.mode.upper()}"
|
mode_text = f"Mode: {state.mode.upper()}"
|
||||||
text = self.small_font.render(mode_text, True, mode_color)
|
text = self.medium_font.render(mode_text, True, mode_color)
|
||||||
self.screen.blit(text, (self.screen.get_width() - 120, 8))
|
self.screen.blit(text, (right_x, 10))
|
||||||
|
|
||||||
# Running indicator
|
# Running status
|
||||||
if state.is_running:
|
if state.is_running:
|
||||||
status_text = "RUNNING"
|
status_text = "● RUNNING"
|
||||||
status_color = (100, 200, 100)
|
status_color = Colors.TEXT_SUCCESS
|
||||||
else:
|
else:
|
||||||
status_text = "STOPPED"
|
status_text = "○ STOPPED"
|
||||||
status_color = Colors.TEXT_DANGER
|
status_color = Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
text = self.small_font.render(status_text, True, status_color)
|
text = self.medium_font.render(status_text, True, status_color)
|
||||||
self.screen.blit(text, (self.screen.get_width() - 120, 28))
|
self.screen.blit(text, (right_x, 28))
|
||||||
|
|
||||||
def draw_right_panel(self, state: "SimulationState") -> None:
|
def draw_right_panel(self, state: "SimulationState") -> None:
|
||||||
"""Draw the right information panel."""
|
"""Draw the right information panel with scrollable content."""
|
||||||
panel_x = self.screen.get_width() - self.right_panel_width
|
panel_x = self.screen.get_width() - self.right_panel_width
|
||||||
|
panel_height = self.screen.get_height() - self.top_panel_height - self.bottom_panel_height
|
||||||
|
|
||||||
|
# Main panel background
|
||||||
rect = pygame.Rect(
|
rect = pygame.Rect(
|
||||||
panel_x,
|
panel_x, self.top_panel_height,
|
||||||
self.top_panel_height,
|
self.right_panel_width, panel_height,
|
||||||
self.right_panel_width,
|
|
||||||
self.screen.get_height() - self.top_panel_height,
|
|
||||||
)
|
)
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||||
pygame.draw.line(
|
pygame.draw.line(
|
||||||
self.screen,
|
self.screen, Colors.PANEL_BORDER,
|
||||||
Colors.PANEL_BORDER,
|
|
||||||
(panel_x, self.top_panel_height),
|
(panel_x, self.top_panel_height),
|
||||||
(panel_x, self.screen.get_height()),
|
(panel_x, self.screen.get_height() - self.bottom_panel_height),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Content area with padding
|
||||||
|
content_x = panel_x + 12
|
||||||
|
content_width = self.right_panel_width - 24
|
||||||
y = self.top_panel_height + 10
|
y = self.top_panel_height + 10
|
||||||
|
|
||||||
# Statistics section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
y = self._draw_statistics_section(state, panel_x + 10, y)
|
# STATISTICS SECTION
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
y = self._draw_stats_section(state, content_x, y, content_width)
|
||||||
|
y += 15
|
||||||
|
|
||||||
# Market section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
y = self._draw_market_section(state, panel_x + 10, y + 20)
|
# FACTIONS SECTION
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
y = self._draw_factions_section(state, content_x, y, content_width)
|
||||||
|
y += 15
|
||||||
|
|
||||||
# Controls help section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
self._draw_controls_help(panel_x + 10, self.screen.get_height() - 100)
|
# RELIGION SECTION
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
y = self._draw_religion_section(state, content_x, y, content_width)
|
||||||
|
y += 15
|
||||||
|
|
||||||
def _draw_statistics_section(self, state: "SimulationState", x: int, y: int) -> int:
|
# ═══════════════════════════════════════════════════════════════
|
||||||
"""Draw the statistics section."""
|
# DIPLOMACY SECTION
|
||||||
# Title
|
# ═══════════════════════════════════════════════════════════════
|
||||||
title = self.title_font.render("Statistics", True, Colors.TEXT_PRIMARY)
|
y = self._draw_diplomacy_section(state, content_x, y, content_width)
|
||||||
self.screen.blit(title, (x, y))
|
y += 15
|
||||||
y += 30
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# MARKET SECTION
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
y = self._draw_market_section(state, content_x, y, content_width)
|
||||||
|
|
||||||
|
def _draw_stats_section(
|
||||||
|
self, state: "SimulationState", x: int, y: int, width: int
|
||||||
|
) -> int:
|
||||||
|
"""Draw statistics section."""
|
||||||
|
# Section header
|
||||||
|
text = self.title_font.render("📊 Statistics", True, Colors.TEXT_HIGHLIGHT)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 24
|
||||||
|
|
||||||
stats = state.statistics
|
stats = state.statistics
|
||||||
|
|
||||||
|
# Population bar
|
||||||
living = len(state.get_living_agents())
|
living = len(state.get_living_agents())
|
||||||
|
total = len(state.agents)
|
||||||
|
pop_color = Colors.TEXT_SUCCESS if living > total * 0.5 else Colors.TEXT_WARNING
|
||||||
|
|
||||||
# Population
|
pygame.draw.rect(
|
||||||
pop_color = Colors.TEXT_PRIMARY if living > 2 else Colors.TEXT_DANGER
|
self.screen, Colors.SCROLLBAR_BG,
|
||||||
text = self.small_font.render(f"Population: {living}", True, pop_color)
|
(x, y, width, 14), border_radius=2,
|
||||||
self.screen.blit(text, (x, y))
|
)
|
||||||
y += 18
|
if total > 0:
|
||||||
|
ratio = living / total
|
||||||
|
pygame.draw.rect(
|
||||||
|
self.screen, pop_color,
|
||||||
|
(x, y, int(width * ratio), 14), border_radius=2,
|
||||||
|
)
|
||||||
|
pop_text = self.small_font.render(f"Pop: {living}/{total}", True, Colors.TEXT_PRIMARY)
|
||||||
|
self.screen.blit(pop_text, (x + 4, y + 1))
|
||||||
|
y += 20
|
||||||
|
|
||||||
# Deaths
|
# Deaths and money
|
||||||
deaths = stats.get("total_agents_died", 0)
|
deaths = stats.get("total_agents_died", 0)
|
||||||
if deaths > 0:
|
|
||||||
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_WARNING)
|
|
||||||
self.screen.blit(text, (x, y))
|
|
||||||
y += 18
|
|
||||||
|
|
||||||
# Total money
|
|
||||||
total_money = stats.get("total_money_in_circulation", 0)
|
total_money = stats.get("total_money_in_circulation", 0)
|
||||||
text = self.small_font.render(f"Total Coins: {total_money}", True, Colors.TEXT_SECONDARY)
|
|
||||||
self.screen.blit(text, (x, y))
|
|
||||||
y += 18
|
|
||||||
|
|
||||||
# Professions
|
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_DANGER)
|
||||||
professions = stats.get("professions", {})
|
self.screen.blit(text, (x, y))
|
||||||
if professions:
|
|
||||||
y += 5
|
text = self.small_font.render(f"💰 {total_money}", True, (255, 215, 0))
|
||||||
text = self.small_font.render("Professions:", True, Colors.TEXT_SECONDARY)
|
self.screen.blit(text, (x + width // 2, y))
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
# Average faith
|
||||||
|
avg_faith = state.get_avg_faith()
|
||||||
|
text = self.small_font.render(f"Avg Faith: {avg_faith:.0f}%", True, Colors.TEXT_SECONDARY)
|
||||||
self.screen.blit(text, (x, y))
|
self.screen.blit(text, (x, y))
|
||||||
y += 16
|
y += 16
|
||||||
|
|
||||||
for prof, count in professions.items():
|
return y
|
||||||
text = self.small_font.render(f" {prof}: {count}", True, Colors.TEXT_SECONDARY)
|
|
||||||
|
def _draw_factions_section(
|
||||||
|
self, state: "SimulationState", x: int, y: int, width: int
|
||||||
|
) -> int:
|
||||||
|
"""Draw factions section with distribution bars."""
|
||||||
|
# Section header
|
||||||
|
text = self.title_font.render("⚔ Factions", True, (180, 160, 120))
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
faction_stats = state.get_faction_stats()
|
||||||
|
total = sum(faction_stats.values()) or 1
|
||||||
|
|
||||||
|
# Sort by count
|
||||||
|
sorted_factions = sorted(
|
||||||
|
faction_stats.items(),
|
||||||
|
key=lambda x: x[1],
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for faction, count in sorted_factions[:5]: # Top 5
|
||||||
|
color = Colors.FACTIONS.get(faction, Colors.FACTIONS["neutral"])
|
||||||
|
ratio = count / total
|
||||||
|
|
||||||
|
# Faction bar
|
||||||
|
bar_width = int(width * 0.6 * ratio * (total / max(1, sorted_factions[0][1])))
|
||||||
|
bar_width = max(4, min(bar_width, int(width * 0.6)))
|
||||||
|
|
||||||
|
pygame.draw.rect(
|
||||||
|
self.screen, (*color, 180),
|
||||||
|
(x, y, bar_width, 10), border_radius=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Faction name and count
|
||||||
|
label = f"{faction[:8]}: {count}"
|
||||||
|
text = self.small_font.render(label, True, color)
|
||||||
|
self.screen.blit(text, (x + bar_width + 8, y - 1))
|
||||||
|
y += 14
|
||||||
|
|
||||||
|
return y
|
||||||
|
|
||||||
|
def _draw_religion_section(
|
||||||
|
self, state: "SimulationState", x: int, y: int, width: int
|
||||||
|
) -> int:
|
||||||
|
"""Draw religion section with distribution."""
|
||||||
|
# Section header
|
||||||
|
text = self.title_font.render("✦ Religions", True, (200, 180, 220))
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
religion_stats = state.get_religion_stats()
|
||||||
|
total = sum(religion_stats.values()) or 1
|
||||||
|
|
||||||
|
# Sort by count
|
||||||
|
sorted_religions = sorted(
|
||||||
|
religion_stats.items(),
|
||||||
|
key=lambda x: x[1],
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for religion, count in sorted_religions[:5]: # Top 5
|
||||||
|
color = Colors.RELIGIONS.get(religion, Colors.RELIGIONS["atheist"])
|
||||||
|
ratio = count / total
|
||||||
|
|
||||||
|
# Religion color dot
|
||||||
|
pygame.draw.circle(self.screen, color, (x + 5, y + 5), 4)
|
||||||
|
|
||||||
|
# Religion name, count, and percentage
|
||||||
|
pct = ratio * 100
|
||||||
|
label = f"{religion[:8]}: {count} ({pct:.0f}%)"
|
||||||
|
text = self.small_font.render(label, True, Colors.TEXT_SECONDARY)
|
||||||
|
self.screen.blit(text, (x + 14, y))
|
||||||
|
y += 14
|
||||||
|
|
||||||
|
return y
|
||||||
|
|
||||||
|
def _draw_diplomacy_section(
|
||||||
|
self, state: "SimulationState", x: int, y: int, width: int
|
||||||
|
) -> int:
|
||||||
|
"""Draw diplomacy section with wars and treaties."""
|
||||||
|
# Section header
|
||||||
|
text = self.title_font.render("🏛 Diplomacy", True, (120, 180, 200))
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
# Active wars
|
||||||
|
active_wars = state.active_wars
|
||||||
|
if active_wars:
|
||||||
|
text = self.small_font.render("Active Wars:", True, Colors.TEXT_DANGER)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 14
|
||||||
|
|
||||||
|
for war in active_wars[:3]: # Show up to 3 wars
|
||||||
|
f1 = war.get("faction1", "?")[:6]
|
||||||
|
f2 = war.get("faction2", "?")[:6]
|
||||||
|
c1 = Colors.FACTIONS.get(war.get("faction1", "neutral"), (150, 150, 150))
|
||||||
|
c2 = Colors.FACTIONS.get(war.get("faction2", "neutral"), (150, 150, 150))
|
||||||
|
|
||||||
|
# War indicator
|
||||||
|
pygame.draw.circle(self.screen, c1, (x + 5, y + 5), 4)
|
||||||
|
text = self.small_font.render(" ⚔ ", True, (200, 80, 80))
|
||||||
|
self.screen.blit(text, (x + 12, y - 1))
|
||||||
|
pygame.draw.circle(self.screen, c2, (x + 35, y + 5), 4)
|
||||||
|
|
||||||
|
war_text = f"{f1} vs {f2}"
|
||||||
|
text = self.small_font.render(war_text, True, Colors.TEXT_SECONDARY)
|
||||||
|
self.screen.blit(text, (x + 45, y))
|
||||||
|
y += 14
|
||||||
|
else:
|
||||||
|
text = self.small_font.render("☮ No active wars", True, Colors.TEXT_SUCCESS)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 14
|
||||||
|
|
||||||
|
# Peace treaties
|
||||||
|
peace_treaties = state.peace_treaties
|
||||||
|
if peace_treaties:
|
||||||
|
text = self.small_font.render(
|
||||||
|
f"Peace Treaties: {len(peace_treaties)}",
|
||||||
|
True, Colors.TEXT_SUCCESS
|
||||||
|
)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 14
|
||||||
|
|
||||||
|
# Recent diplomatic events
|
||||||
|
recent_events = state.diplomatic_events[:2]
|
||||||
|
if recent_events:
|
||||||
|
y += 4
|
||||||
|
for event in recent_events:
|
||||||
|
event_type = event.get("type", "unknown")
|
||||||
|
if event_type == "war_declared":
|
||||||
|
color = Colors.TEXT_DANGER
|
||||||
|
icon = "⚔"
|
||||||
|
elif event_type == "peace_made":
|
||||||
|
color = Colors.TEXT_SUCCESS
|
||||||
|
icon = "☮"
|
||||||
|
else:
|
||||||
|
color = Colors.TEXT_SECONDARY
|
||||||
|
icon = "•"
|
||||||
|
|
||||||
|
desc = event.get("description", event_type)[:25]
|
||||||
|
text = self.small_font.render(f"{icon} {desc}", True, color)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 12
|
||||||
|
|
||||||
|
return y
|
||||||
|
|
||||||
|
def _draw_market_section(
|
||||||
|
self, state: "SimulationState", x: int, y: int, width: int
|
||||||
|
) -> int:
|
||||||
|
"""Draw market section with prices."""
|
||||||
|
# Section header
|
||||||
|
text = self.title_font.render("💹 Market", True, (100, 200, 150))
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
# Order count
|
||||||
|
order_count = len(state.market_orders)
|
||||||
|
text = self.small_font.render(
|
||||||
|
f"Active Orders: {order_count}",
|
||||||
|
True, Colors.TEXT_SECONDARY
|
||||||
|
)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
# Price summary (show resources with stock)
|
||||||
|
prices = state.market_prices
|
||||||
|
shown = 0
|
||||||
|
for resource, data in sorted(prices.items()):
|
||||||
|
if shown >= 6: # Limit display
|
||||||
|
break
|
||||||
|
|
||||||
|
total_available = data.get("total_available", 0)
|
||||||
|
if total_available > 0:
|
||||||
|
price = data.get("lowest_price", "?")
|
||||||
|
|
||||||
|
# Resource color coding
|
||||||
|
if "oil" in resource.lower() or "fuel" in resource.lower():
|
||||||
|
res_color = (180, 160, 100)
|
||||||
|
elif "meat" in resource.lower():
|
||||||
|
res_color = (200, 120, 100)
|
||||||
|
elif "water" in resource.lower():
|
||||||
|
res_color = (100, 160, 200)
|
||||||
|
else:
|
||||||
|
res_color = Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
|
res_text = f"{resource[:6]}: {total_available}x @ {price}c"
|
||||||
|
text = self.small_font.render(res_text, True, res_color)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 14
|
||||||
|
shown += 1
|
||||||
|
|
||||||
|
if shown == 0:
|
||||||
|
text = self.small_font.render("No items for sale", True, Colors.TEXT_SECONDARY)
|
||||||
self.screen.blit(text, (x, y))
|
self.screen.blit(text, (x, y))
|
||||||
y += 14
|
y += 14
|
||||||
|
|
||||||
return y
|
return y
|
||||||
|
|
||||||
def _draw_market_section(self, state: "SimulationState", x: int, y: int) -> int:
|
def draw_bottom_bar(self, state: "SimulationState") -> None:
|
||||||
"""Draw the market section."""
|
"""Draw bottom information bar with event log."""
|
||||||
# Title
|
bar_y = self.screen.get_height() - self.bottom_panel_height
|
||||||
title = self.title_font.render("Market", True, Colors.TEXT_PRIMARY)
|
rect = pygame.Rect(0, bar_y, self.screen.get_width(), self.bottom_panel_height)
|
||||||
self.screen.blit(title, (x, y))
|
|
||||||
y += 30
|
|
||||||
|
|
||||||
# Order count
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||||
order_count = len(state.market_orders)
|
|
||||||
text = self.small_font.render(f"Active Orders: {order_count}", True, Colors.TEXT_SECONDARY)
|
|
||||||
self.screen.blit(text, (x, y))
|
|
||||||
y += 20
|
|
||||||
|
|
||||||
# Price summary for each resource with available stock
|
|
||||||
prices = state.market_prices
|
|
||||||
for resource, data in prices.items():
|
|
||||||
if data.get("total_available", 0) > 0:
|
|
||||||
price = data.get("lowest_price", "?")
|
|
||||||
qty = data.get("total_available", 0)
|
|
||||||
text = self.small_font.render(
|
|
||||||
f"{resource}: {qty}x @ {price}c",
|
|
||||||
True,
|
|
||||||
Colors.TEXT_SECONDARY,
|
|
||||||
)
|
|
||||||
self.screen.blit(text, (x, y))
|
|
||||||
y += 16
|
|
||||||
|
|
||||||
return y
|
|
||||||
|
|
||||||
def _draw_controls_help(self, x: int, y: int) -> None:
|
|
||||||
"""Draw controls help at bottom of panel."""
|
|
||||||
pygame.draw.line(
|
pygame.draw.line(
|
||||||
self.screen,
|
self.screen, Colors.PANEL_BORDER,
|
||||||
Colors.PANEL_BORDER,
|
(0, bar_y), (self.screen.get_width(), bar_y),
|
||||||
(x - 5, y - 10),
|
|
||||||
(self.screen.get_width() - 5, y - 10),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
title = self.small_font.render("Controls", True, Colors.TEXT_PRIMARY)
|
# Recent events (religious + diplomatic)
|
||||||
self.screen.blit(title, (x, y))
|
x = 15
|
||||||
y += 20
|
y = bar_y + 8
|
||||||
|
|
||||||
controls = [
|
text = self.medium_font.render("Recent Events:", True, Colors.TEXT_SECONDARY)
|
||||||
"SPACE - Next Turn",
|
self.screen.blit(text, (x, y))
|
||||||
"R - Reset Simulation",
|
x += 120
|
||||||
"M - Toggle Mode",
|
|
||||||
"S - Settings",
|
# Show recent religious events
|
||||||
"ESC - Quit",
|
for event in state.religious_events[:2]:
|
||||||
]
|
event_type = event.get("type", "")
|
||||||
|
desc = event.get("description", event_type)[:30]
|
||||||
for control in controls:
|
|
||||||
text = self.small_font.render(control, True, Colors.TEXT_SECONDARY)
|
if event_type == "conversion":
|
||||||
|
color = Colors.RELIGIONS.get(event.get("to_religion", "atheist"), (150, 150, 150))
|
||||||
|
elif event_type == "prayer":
|
||||||
|
color = (180, 160, 220)
|
||||||
|
else:
|
||||||
|
color = Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
|
text = self.small_font.render(f"✦ {desc}", True, color)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
x += text.get_width() + 20
|
||||||
|
|
||||||
|
# Show recent diplomatic events
|
||||||
|
for event in state.diplomatic_events[:2]:
|
||||||
|
event_type = event.get("type", "")
|
||||||
|
desc = event.get("description", event_type)[:30]
|
||||||
|
|
||||||
|
if "war" in event_type.lower():
|
||||||
|
color = Colors.TEXT_DANGER
|
||||||
|
icon = "⚔"
|
||||||
|
elif "peace" in event_type.lower():
|
||||||
|
color = Colors.TEXT_SUCCESS
|
||||||
|
icon = "☮"
|
||||||
|
else:
|
||||||
|
color = Colors.TEXT_SECONDARY
|
||||||
|
icon = "🏛"
|
||||||
|
|
||||||
|
text = self.small_font.render(f"{icon} {desc}", True, color)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
x += text.get_width() + 20
|
||||||
|
|
||||||
|
# If no events, show placeholder
|
||||||
|
if not state.religious_events and not state.diplomatic_events:
|
||||||
|
text = self.small_font.render(
|
||||||
|
"No recent events",
|
||||||
|
True, Colors.TEXT_SECONDARY
|
||||||
|
)
|
||||||
self.screen.blit(text, (x, y))
|
self.screen.blit(text, (x, y))
|
||||||
y += 16
|
|
||||||
|
|
||||||
def draw_connection_status(self, connected: bool) -> None:
|
def draw_connection_status(self, connected: bool) -> None:
|
||||||
"""Draw connection status overlay when disconnected."""
|
"""Draw connection status overlay when disconnected."""
|
||||||
@ -220,20 +594,52 @@ class UIRenderer:
|
|||||||
|
|
||||||
# Semi-transparent overlay
|
# Semi-transparent overlay
|
||||||
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
||||||
overlay.fill((0, 0, 0, 180))
|
overlay.fill((0, 0, 0, 200))
|
||||||
self.screen.blit(overlay, (0, 0))
|
self.screen.blit(overlay, (0, 0))
|
||||||
|
|
||||||
# Connection message
|
# Connection box
|
||||||
text = self.title_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
|
box_w, box_h = 400, 150
|
||||||
text_rect = text.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2))
|
box_x = (self.screen.get_width() - box_w) // 2
|
||||||
|
box_y = (self.screen.get_height() - box_h) // 2
|
||||||
|
|
||||||
|
pygame.draw.rect(
|
||||||
|
self.screen, Colors.PANEL_BG,
|
||||||
|
(box_x, box_y, box_w, box_h), border_radius=10,
|
||||||
|
)
|
||||||
|
pygame.draw.rect(
|
||||||
|
self.screen, Colors.PANEL_ACCENT,
|
||||||
|
(box_x, box_y, box_w, box_h), 2, border_radius=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pulsing dot
|
||||||
|
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||||
|
dot_color = (int(255 * pulse), int(180 * pulse), int(80 * pulse))
|
||||||
|
pygame.draw.circle(
|
||||||
|
self.screen, dot_color,
|
||||||
|
(box_x + 30, box_y + 40), 8,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Text
|
||||||
|
text = self.large_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
|
||||||
|
text_rect = text.get_rect(center=(self.screen.get_width() // 2, box_y + 40))
|
||||||
self.screen.blit(text, text_rect)
|
self.screen.blit(text, text_rect)
|
||||||
|
|
||||||
hint = self.small_font.render("Make sure the backend is running on localhost:8000", True, Colors.TEXT_SECONDARY)
|
hint = self.medium_font.render(
|
||||||
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2 + 30))
|
"Make sure the backend is running on localhost:8000",
|
||||||
|
True, Colors.TEXT_SECONDARY
|
||||||
|
)
|
||||||
|
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, box_y + 80))
|
||||||
self.screen.blit(hint, hint_rect)
|
self.screen.blit(hint, hint_rect)
|
||||||
|
|
||||||
|
cmd = self.small_font.render(
|
||||||
|
"Run: python -m backend.main",
|
||||||
|
True, Colors.TEXT_HIGHLIGHT
|
||||||
|
)
|
||||||
|
cmd_rect = cmd.get_rect(center=(self.screen.get_width() // 2, box_y + 110))
|
||||||
|
self.screen.blit(cmd, cmd_rect)
|
||||||
|
|
||||||
def draw(self, state: "SimulationState") -> None:
|
def draw(self, state: "SimulationState") -> None:
|
||||||
"""Draw all UI elements."""
|
"""Draw all UI elements."""
|
||||||
self.draw_top_bar(state)
|
self.draw_top_bar(state)
|
||||||
self.draw_right_panel(state)
|
self.draw_right_panel(state)
|
||||||
|
self.draw_bottom_bar(state)
|
||||||
|
|||||||
83
tools/debug_diplomacy.py
Normal file
83
tools/debug_diplomacy.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Debug script for diplomacy relations."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from backend.core.engine import GameEngine
|
||||||
|
from backend.domain.diplomacy import FactionType, get_faction_relations, DiplomaticStatus
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Debugging diplomacy relations...\n")
|
||||||
|
|
||||||
|
engine = GameEngine()
|
||||||
|
engine.initialize(50)
|
||||||
|
|
||||||
|
faction_relations = get_faction_relations()
|
||||||
|
|
||||||
|
print("Initial Relations:")
|
||||||
|
factions = [f for f in FactionType if f != FactionType.NEUTRAL]
|
||||||
|
for f1 in factions:
|
||||||
|
for f2 in factions:
|
||||||
|
if f1 != f2:
|
||||||
|
rel = faction_relations.get_relation(f1, f2)
|
||||||
|
status = faction_relations.get_status(f1, f2)
|
||||||
|
print(f" {f1.value:12s} -> {f2.value:12s}: {rel:3d} ({status.value})")
|
||||||
|
|
||||||
|
# Run 50 turns and check relations
|
||||||
|
print("\n\nRunning 50 turns...")
|
||||||
|
for step in range(50):
|
||||||
|
engine.next_step()
|
||||||
|
|
||||||
|
print("\nAfter 50 turns:")
|
||||||
|
hostile_pairs = []
|
||||||
|
for f1 in factions:
|
||||||
|
for f2 in factions:
|
||||||
|
if f1.value < f2.value: # Avoid duplicates
|
||||||
|
rel = faction_relations.get_relation(f1, f2)
|
||||||
|
status = faction_relations.get_status(f1, f2)
|
||||||
|
marker = "⚔️" if status == DiplomaticStatus.HOSTILE else ""
|
||||||
|
print(f" {f1.value:12s} <-> {f2.value:12s}: {rel:3d} ({status.value:8s}) {marker}")
|
||||||
|
if status == DiplomaticStatus.HOSTILE:
|
||||||
|
hostile_pairs.append((f1, f2, rel))
|
||||||
|
|
||||||
|
print(f"\nHostile pairs: {len(hostile_pairs)}")
|
||||||
|
for f1, f2, rel in hostile_pairs:
|
||||||
|
print(f" {f1.value} vs {f2.value}: {rel}")
|
||||||
|
|
||||||
|
# Run 50 more turns
|
||||||
|
print("\n\nRunning 50 more turns...")
|
||||||
|
for step in range(50):
|
||||||
|
engine.next_step()
|
||||||
|
|
||||||
|
print("\nAfter 100 turns:")
|
||||||
|
hostile_pairs = []
|
||||||
|
war_pairs = []
|
||||||
|
for f1 in factions:
|
||||||
|
for f2 in factions:
|
||||||
|
if f1.value < f2.value:
|
||||||
|
rel = faction_relations.get_relation(f1, f2)
|
||||||
|
status = faction_relations.get_status(f1, f2)
|
||||||
|
if status == DiplomaticStatus.HOSTILE:
|
||||||
|
hostile_pairs.append((f1, f2, rel))
|
||||||
|
elif status == DiplomaticStatus.WAR:
|
||||||
|
war_pairs.append((f1, f2, rel))
|
||||||
|
print(f" {f1.value:12s} <-> {f2.value:12s}: {rel:3d} ({status.value:8s})")
|
||||||
|
|
||||||
|
print(f"\nHostile pairs: {len(hostile_pairs)}")
|
||||||
|
print(f"War pairs: {len(war_pairs)}")
|
||||||
|
|
||||||
|
if war_pairs:
|
||||||
|
print("\n🔥 WARS ACTIVE:")
|
||||||
|
for f1, f2, rel in war_pairs:
|
||||||
|
print(f" {f1.value} vs {f2.value}")
|
||||||
|
|
||||||
|
stats = engine.world.get_statistics()
|
||||||
|
print(f"\nTotal wars declared: {stats.get('total_wars', 0)}")
|
||||||
|
print(f"Active wars: {faction_relations.active_wars}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
738
tools/optimize_balance.py
Normal file
738
tools/optimize_balance.py
Normal file
@ -0,0 +1,738 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Comprehensive Balance Optimizer for Village Simulation
|
||||||
|
|
||||||
|
This script runs simulations and optimizes config values for:
|
||||||
|
- High survival rate (target: >50% at end)
|
||||||
|
- Religion diversity (no single religion >60%)
|
||||||
|
- Faction survival (all factions have living members)
|
||||||
|
- Active market (trades happening, money circulating)
|
||||||
|
- Oil industry activity (drilling and refining)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/optimize_balance.py [--iterations 20] [--steps 1000]
|
||||||
|
python tools/optimize_balance.py --quick-test
|
||||||
|
python tools/optimize_balance.py --analyze-current
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# 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.core.logger import reset_simulation_logger
|
||||||
|
from backend.domain.action import reset_action_config_cache
|
||||||
|
from backend.domain.resources import reset_resource_cache
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BalanceMetrics:
|
||||||
|
"""Comprehensive metrics for simulation balance."""
|
||||||
|
total_turns: int = 0
|
||||||
|
initial_population: int = 0
|
||||||
|
final_population: int = 0
|
||||||
|
|
||||||
|
# Survival tracking
|
||||||
|
deaths_by_cause: dict = field(default_factory=lambda: defaultdict(int))
|
||||||
|
population_over_time: list = field(default_factory=list)
|
||||||
|
|
||||||
|
# Religion tracking
|
||||||
|
religion_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||||
|
conversions: int = 0
|
||||||
|
|
||||||
|
# Faction tracking
|
||||||
|
faction_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||||
|
wars_declared: int = 0
|
||||||
|
peace_treaties: int = 0
|
||||||
|
|
||||||
|
# Market tracking
|
||||||
|
total_listings: int = 0
|
||||||
|
total_trades: int = 0
|
||||||
|
trade_volume: int = 0
|
||||||
|
trade_value: int = 0
|
||||||
|
trades_by_resource: dict = field(default_factory=lambda: defaultdict(int))
|
||||||
|
|
||||||
|
# Action diversity
|
||||||
|
action_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||||
|
|
||||||
|
# Oil industry
|
||||||
|
oil_drilled: int = 0
|
||||||
|
fuel_refined: int = 0
|
||||||
|
|
||||||
|
# Economy
|
||||||
|
money_circulation: list = field(default_factory=list)
|
||||||
|
avg_wealth: list = field(default_factory=list)
|
||||||
|
wealth_gini: list = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def survival_rate(self) -> float:
|
||||||
|
"""Final survival rate."""
|
||||||
|
if self.initial_population == 0:
|
||||||
|
return 0
|
||||||
|
return self.final_population / self.initial_population
|
||||||
|
|
||||||
|
@property
|
||||||
|
def religion_diversity(self) -> float:
|
||||||
|
"""Religion diversity score (0-1, higher = more diverse)."""
|
||||||
|
if not self.religion_counts:
|
||||||
|
return 0
|
||||||
|
total = sum(self.religion_counts.values())
|
||||||
|
if total == 0:
|
||||||
|
return 0
|
||||||
|
max_count = max(self.religion_counts.values())
|
||||||
|
# Perfect diversity = 20% each (5 religions), worst = 100% one religion
|
||||||
|
return 1.0 - (max_count / total)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dominant_religion_pct(self) -> float:
|
||||||
|
"""Percentage held by dominant religion."""
|
||||||
|
if not self.religion_counts:
|
||||||
|
return 0
|
||||||
|
total = sum(self.religion_counts.values())
|
||||||
|
if total == 0:
|
||||||
|
return 0
|
||||||
|
return max(self.religion_counts.values()) / total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def factions_alive(self) -> int:
|
||||||
|
"""Number of factions with living members."""
|
||||||
|
return len([f for f, c in self.faction_counts.items() if c > 0])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def faction_diversity(self) -> float:
|
||||||
|
"""Faction diversity (0-1)."""
|
||||||
|
if not self.faction_counts:
|
||||||
|
return 0
|
||||||
|
alive = self.factions_alive
|
||||||
|
# We have 5 non-neutral factions
|
||||||
|
return alive / 5.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def market_activity(self) -> float:
|
||||||
|
"""Market activity score."""
|
||||||
|
if self.total_turns == 0:
|
||||||
|
return 0
|
||||||
|
trades_per_turn = self.total_trades / self.total_turns
|
||||||
|
# Target: 0.3 trades per turn per 10 agents
|
||||||
|
return min(1.0, trades_per_turn / 0.3)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trade_diversity(self) -> float:
|
||||||
|
"""How many different resources are being traded."""
|
||||||
|
resources_traded = len([r for r, c in self.trades_by_resource.items() if c > 0])
|
||||||
|
return resources_traded / 6.0 # 6 tradeable resources
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oil_industry_activity(self) -> float:
|
||||||
|
"""Oil industry health score."""
|
||||||
|
total_oil_ops = self.oil_drilled + self.fuel_refined
|
||||||
|
# Target: 5% of actions should be oil-related
|
||||||
|
total_actions = sum(self.action_counts.values())
|
||||||
|
if total_actions == 0:
|
||||||
|
return 0
|
||||||
|
return min(1.0, (total_oil_ops / total_actions) / 0.05)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def economy_health(self) -> float:
|
||||||
|
"""Overall economy health."""
|
||||||
|
if not self.avg_wealth:
|
||||||
|
return 0
|
||||||
|
final_wealth = self.avg_wealth[-1]
|
||||||
|
# Target: average wealth should stay above 50
|
||||||
|
return min(1.0, final_wealth / 50)
|
||||||
|
|
||||||
|
def score(self) -> float:
|
||||||
|
"""Calculate overall balance score (0-100)."""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Survival rate (0-30 points) - CRITICAL
|
||||||
|
# Target: at least 30% survival
|
||||||
|
survival_score = min(30, self.survival_rate * 100)
|
||||||
|
score += survival_score
|
||||||
|
|
||||||
|
# Religion diversity (0-15 points)
|
||||||
|
# Target: no single religion > 50%
|
||||||
|
religion_score = self.religion_diversity * 15
|
||||||
|
score += religion_score
|
||||||
|
|
||||||
|
# Faction survival (0-15 points)
|
||||||
|
# Target: at least 4 of 5 factions alive
|
||||||
|
faction_score = self.faction_diversity * 15
|
||||||
|
score += faction_score
|
||||||
|
|
||||||
|
# Market activity (0-15 points)
|
||||||
|
market_score = self.market_activity * 15
|
||||||
|
score += market_score
|
||||||
|
|
||||||
|
# Trade diversity (0-10 points)
|
||||||
|
trade_div_score = self.trade_diversity * 10
|
||||||
|
score += trade_div_score
|
||||||
|
|
||||||
|
# Oil industry (0-10 points)
|
||||||
|
oil_score = self.oil_industry_activity * 10
|
||||||
|
score += oil_score
|
||||||
|
|
||||||
|
# Economy health (0-5 points)
|
||||||
|
econ_score = self.economy_health * 5
|
||||||
|
score += econ_score
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def run_simulation(config_overrides: dict, num_steps: int = 1000, num_agents: int = 100) -> BalanceMetrics:
|
||||||
|
"""Run a simulation with custom config and return metrics."""
|
||||||
|
# Apply config overrides
|
||||||
|
config_path = Path("config.json")
|
||||||
|
with open(config_path) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
# Deep merge overrides
|
||||||
|
for section, values in config_overrides.items():
|
||||||
|
if section in config:
|
||||||
|
config[section].update(values)
|
||||||
|
else:
|
||||||
|
config[section] = values
|
||||||
|
|
||||||
|
# Save temp config
|
||||||
|
temp_config = Path("config_temp.json")
|
||||||
|
with open(temp_config, 'w') as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
# Reload config
|
||||||
|
reload_config(str(temp_config))
|
||||||
|
reset_action_config_cache()
|
||||||
|
reset_resource_cache()
|
||||||
|
|
||||||
|
# Initialize engine - need to set initial_agents BEFORE reset() calls initialize()
|
||||||
|
GameEngine._instance = None # Reset singleton
|
||||||
|
engine = GameEngine()
|
||||||
|
# Note: reset() already calls world.initialize(), so we must set initial_agents first
|
||||||
|
# Get the config and modify it before reset
|
||||||
|
sim_config = get_config()
|
||||||
|
engine.world.config.initial_agents = num_agents
|
||||||
|
# Reset creates a new world and initializes it
|
||||||
|
from backend.core.world import World, WorldConfig
|
||||||
|
world_config = WorldConfig(initial_agents=num_agents)
|
||||||
|
engine.reset(config=world_config)
|
||||||
|
|
||||||
|
# Suppress logging
|
||||||
|
import logging
|
||||||
|
logging.getLogger("simulation").setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
metrics = BalanceMetrics()
|
||||||
|
metrics.initial_population = num_agents
|
||||||
|
|
||||||
|
# Run simulation
|
||||||
|
for step in range(num_steps):
|
||||||
|
if not engine.is_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
turn_log = engine.next_step()
|
||||||
|
metrics.total_turns += 1
|
||||||
|
|
||||||
|
# Track population
|
||||||
|
living = len(engine.world.get_living_agents())
|
||||||
|
metrics.population_over_time.append(living)
|
||||||
|
|
||||||
|
# Track money
|
||||||
|
agents = engine.world.get_living_agents()
|
||||||
|
if agents:
|
||||||
|
total_money = sum(a.money for a in agents)
|
||||||
|
avg_money = total_money / len(agents)
|
||||||
|
metrics.money_circulation.append(total_money)
|
||||||
|
metrics.avg_wealth.append(avg_money)
|
||||||
|
|
||||||
|
# Gini coefficient
|
||||||
|
moneys = sorted([a.money for a in agents])
|
||||||
|
n = len(moneys)
|
||||||
|
if n > 1 and total_money > 0:
|
||||||
|
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
||||||
|
gini = sum_of_diffs / (2 * n * total_money)
|
||||||
|
else:
|
||||||
|
gini = 0
|
||||||
|
metrics.wealth_gini.append(gini)
|
||||||
|
|
||||||
|
# Process actions
|
||||||
|
for action_data in turn_log.agent_actions:
|
||||||
|
decision = action_data.get("decision", {})
|
||||||
|
result = action_data.get("result", {})
|
||||||
|
action_type = decision.get("action", "unknown")
|
||||||
|
|
||||||
|
metrics.action_counts[action_type] += 1
|
||||||
|
|
||||||
|
# Track specific actions
|
||||||
|
if action_type == "drill_oil" and result.get("success"):
|
||||||
|
for res in result.get("resources_gained", []):
|
||||||
|
if res.get("type") == "oil":
|
||||||
|
metrics.oil_drilled += res.get("quantity", 0)
|
||||||
|
|
||||||
|
elif action_type == "refine" and result.get("success"):
|
||||||
|
for res in result.get("resources_gained", []):
|
||||||
|
if res.get("type") == "fuel":
|
||||||
|
metrics.fuel_refined += res.get("quantity", 0)
|
||||||
|
|
||||||
|
elif action_type == "preach" and result.get("success"):
|
||||||
|
if "converted" in result.get("message", "").lower():
|
||||||
|
metrics.conversions += 1
|
||||||
|
|
||||||
|
elif action_type == "declare_war" and result.get("success"):
|
||||||
|
metrics.wars_declared += 1
|
||||||
|
|
||||||
|
elif action_type == "make_peace" and result.get("success"):
|
||||||
|
metrics.peace_treaties += 1
|
||||||
|
|
||||||
|
elif action_type == "trade" and result.get("success"):
|
||||||
|
message = result.get("message", "")
|
||||||
|
if "Listed" in message:
|
||||||
|
metrics.total_listings += 1
|
||||||
|
elif "Bought" in message:
|
||||||
|
match = re.search(r"Bought (\d+) (\w+) for (\d+)c", message)
|
||||||
|
if match:
|
||||||
|
qty = int(match.group(1))
|
||||||
|
res = match.group(2)
|
||||||
|
value = int(match.group(3))
|
||||||
|
metrics.total_trades += 1
|
||||||
|
metrics.trade_volume += qty
|
||||||
|
metrics.trade_value += value
|
||||||
|
metrics.trades_by_resource[res] += 1
|
||||||
|
|
||||||
|
# Process deaths
|
||||||
|
for death_name in turn_log.deaths:
|
||||||
|
for agent in engine.world.agents:
|
||||||
|
if agent.name == death_name and agent.death_reason:
|
||||||
|
metrics.deaths_by_cause[agent.death_reason] += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# Collect final stats
|
||||||
|
living_agents = engine.world.get_living_agents()
|
||||||
|
metrics.final_population = len(living_agents)
|
||||||
|
|
||||||
|
# Count religions and factions
|
||||||
|
for agent in living_agents:
|
||||||
|
metrics.religion_counts[agent.religion.religion.value] += 1
|
||||||
|
metrics.faction_counts[agent.diplomacy.faction.value] += 1
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
engine.logger.close()
|
||||||
|
temp_config.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def generate_balanced_config() -> dict:
|
||||||
|
"""Generate a config focused on balance."""
|
||||||
|
return {
|
||||||
|
"agent_stats": {
|
||||||
|
"start_hunger": random.randint(85, 95),
|
||||||
|
"start_thirst": random.randint(85, 95),
|
||||||
|
"hunger_decay": random.randint(1, 2),
|
||||||
|
"thirst_decay": random.randint(1, 3),
|
||||||
|
"heat_decay": random.randint(1, 2),
|
||||||
|
"faith_decay": random.randint(1, 2),
|
||||||
|
"critical_threshold": round(random.uniform(0.12, 0.20), 2),
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"meat_hunger": random.randint(40, 55),
|
||||||
|
"berries_hunger": random.randint(12, 18),
|
||||||
|
"water_thirst": random.randint(50, 70),
|
||||||
|
"fire_heat": random.randint(25, 40),
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"hunt_success": round(random.uniform(0.75, 0.90), 2),
|
||||||
|
"drill_oil_success": round(random.uniform(0.70, 0.85), 2),
|
||||||
|
"hunt_meat_min": random.randint(3, 4),
|
||||||
|
"hunt_meat_max": random.randint(5, 7),
|
||||||
|
"gather_min": random.randint(4, 5),
|
||||||
|
"gather_max": random.randint(6, 8),
|
||||||
|
# preach_convert_chance is in actions, not religion
|
||||||
|
"preach_convert_chance": round(random.uniform(0.03, 0.08), 2),
|
||||||
|
},
|
||||||
|
"religion": {
|
||||||
|
"conversion_resistance": round(random.uniform(0.65, 0.85), 2),
|
||||||
|
"zealot_threshold": round(random.uniform(0.80, 0.92), 2),
|
||||||
|
"same_religion_bonus": round(random.uniform(0.08, 0.15), 2),
|
||||||
|
"different_religion_penalty": round(random.uniform(0.02, 0.06), 2),
|
||||||
|
},
|
||||||
|
"diplomacy": {
|
||||||
|
"starting_relations": random.randint(55, 70),
|
||||||
|
"relation_decay": random.randint(0, 1),
|
||||||
|
"trade_relation_boost": random.randint(6, 10),
|
||||||
|
"war_exhaustion_rate": random.randint(8, 15),
|
||||||
|
"war_threshold": random.randint(15, 25),
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"buy_efficiency_threshold": round(random.uniform(0.80, 0.95), 2),
|
||||||
|
"min_wealth_target": random.randint(25, 50),
|
||||||
|
"max_price_markup": round(random.uniform(1.4, 1.8), 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mutate_config(config: dict, mutation_rate: float = 0.3) -> dict:
|
||||||
|
"""Mutate a configuration."""
|
||||||
|
new_config = json.loads(json.dumps(config))
|
||||||
|
|
||||||
|
for section, values in new_config.items():
|
||||||
|
for key, value in values.items():
|
||||||
|
if random.random() < mutation_rate:
|
||||||
|
if isinstance(value, int):
|
||||||
|
delta = max(1, abs(value) // 4)
|
||||||
|
new_config[section][key] = max(0, value + random.randint(-delta, delta))
|
||||||
|
elif isinstance(value, float):
|
||||||
|
delta = abs(value) * 0.15
|
||||||
|
new_val = value + random.uniform(-delta, delta)
|
||||||
|
new_config[section][key] = round(max(0.01, min(0.99, new_val)), 2)
|
||||||
|
|
||||||
|
return new_config
|
||||||
|
|
||||||
|
|
||||||
|
def crossover_configs(config1: dict, config2: dict) -> dict:
|
||||||
|
"""Crossover two configurations."""
|
||||||
|
new_config = {}
|
||||||
|
for section in set(config1.keys()) | set(config2.keys()):
|
||||||
|
if section in config1 and section in config2:
|
||||||
|
new_config[section] = {}
|
||||||
|
for key in set(config1[section].keys()) | set(config2[section].keys()):
|
||||||
|
if random.random() < 0.5 and key in config1[section]:
|
||||||
|
new_config[section][key] = config1[section][key]
|
||||||
|
elif key in config2[section]:
|
||||||
|
new_config[section][key] = config2[section][key]
|
||||||
|
elif section in config1:
|
||||||
|
new_config[section] = config1[section].copy()
|
||||||
|
else:
|
||||||
|
new_config[section] = config2[section].copy()
|
||||||
|
return new_config
|
||||||
|
|
||||||
|
|
||||||
|
def print_metrics(metrics: BalanceMetrics, detailed: bool = True):
|
||||||
|
"""Print metrics in a readable format."""
|
||||||
|
print(f"\n 📊 Balance Score: {metrics.score():.1f}/100")
|
||||||
|
print(f" ├─ Survival: {metrics.survival_rate*100:.0f}% ({metrics.final_population}/{metrics.initial_population})")
|
||||||
|
print(f" ├─ Religion: {metrics.religion_diversity*100:.0f}% diversity (dominant: {metrics.dominant_religion_pct*100:.0f}%)")
|
||||||
|
print(f" ├─ Factions: {metrics.factions_alive}/5 alive ({metrics.faction_diversity*100:.0f}%)")
|
||||||
|
print(f" ├─ Market: {metrics.total_trades} trades, {metrics.total_listings} listings")
|
||||||
|
print(f" ├─ Trade diversity: {metrics.trade_diversity*100:.0f}%")
|
||||||
|
print(f" ├─ Oil industry: {metrics.oil_drilled} oil, {metrics.fuel_refined} fuel")
|
||||||
|
print(f" └─ Economy: avg wealth ${metrics.avg_wealth[-1]:.0f}" if metrics.avg_wealth else " └─ Economy: N/A")
|
||||||
|
|
||||||
|
if detailed:
|
||||||
|
print(f"\n 📋 Death causes:")
|
||||||
|
for cause, count in sorted(metrics.deaths_by_cause.items(), key=lambda x: -x[1])[:5]:
|
||||||
|
print(f" - {cause}: {count}")
|
||||||
|
|
||||||
|
print(f"\n 🏛️ Religions:")
|
||||||
|
for religion, count in sorted(metrics.religion_counts.items(), key=lambda x: -x[1]):
|
||||||
|
print(f" - {religion}: {count}")
|
||||||
|
|
||||||
|
print(f"\n ⚔️ Factions:")
|
||||||
|
for faction, count in sorted(metrics.faction_counts.items(), key=lambda x: -x[1]):
|
||||||
|
print(f" - {faction}: {count}")
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_balance(iterations: int = 20, steps_per_sim: int = 1000, population_size: int = 8):
|
||||||
|
"""Run genetic optimization for balance."""
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("🧬 BALANCE OPTIMIZER - Finding Optimal Configuration")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f" Iterations: {iterations}")
|
||||||
|
print(f" Steps per simulation: {steps_per_sim}")
|
||||||
|
print(f" Population size: {population_size}")
|
||||||
|
print(f" Agents per simulation: 100")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Create initial population
|
||||||
|
population = []
|
||||||
|
|
||||||
|
# Start with a well-balanced baseline
|
||||||
|
baseline = {
|
||||||
|
"agent_stats": {
|
||||||
|
"start_hunger": 92,
|
||||||
|
"start_thirst": 92,
|
||||||
|
"hunger_decay": 1,
|
||||||
|
"thirst_decay": 2,
|
||||||
|
"heat_decay": 1,
|
||||||
|
"faith_decay": 1,
|
||||||
|
"critical_threshold": 0.15,
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"meat_hunger": 50,
|
||||||
|
"berries_hunger": 15,
|
||||||
|
"water_thirst": 65,
|
||||||
|
"fire_heat": 35,
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"hunt_success": 0.85,
|
||||||
|
"drill_oil_success": 0.80,
|
||||||
|
"hunt_meat_min": 4,
|
||||||
|
"hunt_meat_max": 6,
|
||||||
|
"gather_min": 4,
|
||||||
|
"gather_max": 7,
|
||||||
|
"preach_convert_chance": 0.05,
|
||||||
|
},
|
||||||
|
"religion": {
|
||||||
|
"conversion_resistance": 0.75,
|
||||||
|
"zealot_threshold": 0.88,
|
||||||
|
"same_religion_bonus": 0.10,
|
||||||
|
"different_religion_penalty": 0.03,
|
||||||
|
},
|
||||||
|
"diplomacy": {
|
||||||
|
"starting_relations": 65,
|
||||||
|
"relation_decay": 0,
|
||||||
|
"trade_relation_boost": 8,
|
||||||
|
"war_exhaustion_rate": 12,
|
||||||
|
"war_threshold": 18,
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"buy_efficiency_threshold": 0.88,
|
||||||
|
"min_wealth_target": 35,
|
||||||
|
"max_price_markup": 1.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
population.append(baseline)
|
||||||
|
|
||||||
|
# Add survival-focused variant
|
||||||
|
survival_focused = json.loads(json.dumps(baseline))
|
||||||
|
survival_focused["agent_stats"]["hunger_decay"] = 1
|
||||||
|
survival_focused["agent_stats"]["thirst_decay"] = 1
|
||||||
|
survival_focused["resources"]["meat_hunger"] = 55
|
||||||
|
survival_focused["resources"]["berries_hunger"] = 18
|
||||||
|
survival_focused["resources"]["water_thirst"] = 70
|
||||||
|
population.append(survival_focused)
|
||||||
|
|
||||||
|
# Add religion-balanced variant
|
||||||
|
religion_balanced = json.loads(json.dumps(baseline))
|
||||||
|
religion_balanced["religion"]["conversion_resistance"] = 0.82
|
||||||
|
religion_balanced["actions"]["preach_convert_chance"] = 0.03
|
||||||
|
religion_balanced["religion"]["zealot_threshold"] = 0.92
|
||||||
|
population.append(religion_balanced)
|
||||||
|
|
||||||
|
# Add diplomacy-stable variant
|
||||||
|
diplomacy_stable = json.loads(json.dumps(baseline))
|
||||||
|
diplomacy_stable["diplomacy"]["relation_decay"] = 0
|
||||||
|
diplomacy_stable["diplomacy"]["starting_relations"] = 70
|
||||||
|
diplomacy_stable["diplomacy"]["war_exhaustion_rate"] = 15
|
||||||
|
population.append(diplomacy_stable)
|
||||||
|
|
||||||
|
# Fill rest with random
|
||||||
|
while len(population) < population_size:
|
||||||
|
population.append(generate_balanced_config())
|
||||||
|
|
||||||
|
best_config = None
|
||||||
|
best_score = 0
|
||||||
|
best_metrics = None
|
||||||
|
|
||||||
|
for gen in range(iterations):
|
||||||
|
print(f"\n📍 Generation {gen + 1}/{iterations}")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
scored_population = []
|
||||||
|
for i, config in enumerate(population):
|
||||||
|
sys.stdout.write(f"\r Evaluating config {i + 1}/{len(population)}...")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
metrics = run_simulation(config, steps_per_sim, num_agents=100)
|
||||||
|
score = metrics.score()
|
||||||
|
scored_population.append((config, metrics, score))
|
||||||
|
|
||||||
|
# Sort by score
|
||||||
|
scored_population.sort(key=lambda x: x[2], reverse=True)
|
||||||
|
|
||||||
|
# Print top results
|
||||||
|
print(f"\r Top configs this generation:")
|
||||||
|
for i, (config, metrics, score) in enumerate(scored_population[:3]):
|
||||||
|
print(f"\n #{i + 1}: Score {score:.1f}")
|
||||||
|
print_metrics(metrics, detailed=False)
|
||||||
|
|
||||||
|
# Track best overall
|
||||||
|
if scored_population[0][2] > best_score:
|
||||||
|
best_config = scored_population[0][0]
|
||||||
|
best_score = scored_population[0][2]
|
||||||
|
best_metrics = scored_population[0][1]
|
||||||
|
print(f"\n ⭐ New best score: {best_score:.1f}")
|
||||||
|
|
||||||
|
# Create next generation
|
||||||
|
new_population = []
|
||||||
|
|
||||||
|
# Keep top 2 (elitism)
|
||||||
|
new_population.append(scored_population[0][0])
|
||||||
|
new_population.append(scored_population[1][0])
|
||||||
|
|
||||||
|
# Crossover and mutate
|
||||||
|
while len(new_population) < population_size:
|
||||||
|
parent1 = random.choice(scored_population[:4])[0]
|
||||||
|
parent2 = random.choice(scored_population[:4])[0]
|
||||||
|
child = crossover_configs(parent1, parent2)
|
||||||
|
child = mutate_config(child, mutation_rate=0.25)
|
||||||
|
new_population.append(child)
|
||||||
|
|
||||||
|
population = new_population
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("🏆 OPTIMIZATION COMPLETE")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"\n Best Score: {best_score:.1f}/100")
|
||||||
|
print_metrics(best_metrics, detailed=True)
|
||||||
|
|
||||||
|
print("\n 📝 Best Configuration:")
|
||||||
|
print("-" * 50)
|
||||||
|
print(json.dumps(best_config, indent=2))
|
||||||
|
|
||||||
|
# Save optimized config
|
||||||
|
output_path = Path("config_balanced.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 optimized config to: {output_path}")
|
||||||
|
print(" To apply: cp config_balanced.json config.json")
|
||||||
|
|
||||||
|
return best_config, best_metrics
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_current_config(steps: int = 500):
|
||||||
|
"""Analyze the current configuration."""
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("📊 ANALYZING CURRENT CONFIGURATION")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
metrics = run_simulation({}, steps, num_agents=100)
|
||||||
|
print_metrics(metrics, detailed=True)
|
||||||
|
|
||||||
|
# Provide recommendations
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("💡 RECOMMENDATIONS")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
if metrics.survival_rate < 0.3:
|
||||||
|
print("\n ⚠️ LOW SURVIVAL RATE")
|
||||||
|
print(" - Reduce hunger_decay and thirst_decay")
|
||||||
|
print(" - Increase food resource values (meat_hunger, berries_hunger)")
|
||||||
|
print(" - Lower critical_threshold")
|
||||||
|
|
||||||
|
if metrics.dominant_religion_pct > 0.6:
|
||||||
|
print("\n ⚠️ RELIGION DOMINANCE")
|
||||||
|
print(" - Increase conversion_resistance (try 0.75+)")
|
||||||
|
print(" - Reduce preach_convert_chance (try 0.05)")
|
||||||
|
print(" - Increase zealot_threshold (try 0.88+)")
|
||||||
|
|
||||||
|
if metrics.factions_alive < 4:
|
||||||
|
print("\n ⚠️ FACTIONS DYING OUT")
|
||||||
|
print(" - Set relation_decay to 0 or 1")
|
||||||
|
print(" - Increase starting_relations (try 65+)")
|
||||||
|
print(" - Increase war_exhaustion_rate (try 10+)")
|
||||||
|
|
||||||
|
if metrics.total_trades < metrics.total_turns * 0.1:
|
||||||
|
print("\n ⚠️ LOW MARKET ACTIVITY")
|
||||||
|
print(" - Increase buy_efficiency_threshold (try 0.9)")
|
||||||
|
print(" - Lower min_wealth_target")
|
||||||
|
print(" - Reduce max_price_markup")
|
||||||
|
|
||||||
|
if metrics.oil_drilled + metrics.fuel_refined < 50:
|
||||||
|
print("\n ⚠️ LOW OIL INDUSTRY")
|
||||||
|
print(" - Increase drill_oil_success (try 0.80)")
|
||||||
|
print(" - Check that factions with oil bonus survive")
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def quick_test(steps: int = 500):
|
||||||
|
"""Quick test with a balanced preset."""
|
||||||
|
print("\n🧪 Quick Test with Balanced Preset")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
test_config = {
|
||||||
|
"agent_stats": {
|
||||||
|
"start_hunger": 92,
|
||||||
|
"start_thirst": 92,
|
||||||
|
"hunger_decay": 1,
|
||||||
|
"thirst_decay": 2,
|
||||||
|
"heat_decay": 1,
|
||||||
|
"faith_decay": 1,
|
||||||
|
"critical_threshold": 0.15,
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"meat_hunger": 50,
|
||||||
|
"berries_hunger": 16,
|
||||||
|
"water_thirst": 65,
|
||||||
|
"fire_heat": 35,
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"hunt_success": 0.85,
|
||||||
|
"drill_oil_success": 0.80,
|
||||||
|
"hunt_meat_min": 4,
|
||||||
|
"hunt_meat_max": 6,
|
||||||
|
"gather_min": 4,
|
||||||
|
"gather_max": 7,
|
||||||
|
"preach_convert_chance": 0.04,
|
||||||
|
},
|
||||||
|
"religion": {
|
||||||
|
"conversion_resistance": 0.78,
|
||||||
|
"zealot_threshold": 0.90,
|
||||||
|
"same_religion_bonus": 0.10,
|
||||||
|
"different_religion_penalty": 0.03,
|
||||||
|
},
|
||||||
|
"diplomacy": {
|
||||||
|
"starting_relations": 65,
|
||||||
|
"relation_decay": 0,
|
||||||
|
"trade_relation_boost": 8,
|
||||||
|
"war_exhaustion_rate": 12,
|
||||||
|
"war_threshold": 18,
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"buy_efficiency_threshold": 0.90,
|
||||||
|
"min_wealth_target": 30,
|
||||||
|
"max_price_markup": 1.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n Testing config:")
|
||||||
|
print(json.dumps(test_config, indent=2))
|
||||||
|
|
||||||
|
metrics = run_simulation(test_config, steps, num_agents=100)
|
||||||
|
print_metrics(metrics, detailed=True)
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Optimize Village Simulation balance")
|
||||||
|
parser.add_argument("--iterations", "-i", type=int, default=15, help="Optimization iterations")
|
||||||
|
parser.add_argument("--steps", "-s", type=int, default=800, help="Steps per simulation")
|
||||||
|
parser.add_argument("--population", "-p", type=int, default=8, help="Population size for GA")
|
||||||
|
parser.add_argument("--quick-test", "-q", action="store_true", help="Quick test balanced preset")
|
||||||
|
parser.add_argument("--analyze-current", "-a", action="store_true", help="Analyze current config")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.quick_test:
|
||||||
|
quick_test(args.steps)
|
||||||
|
elif args.analyze_current:
|
||||||
|
analyze_current_config(args.steps)
|
||||||
|
else:
|
||||||
|
optimize_balance(args.iterations, args.steps, args.population)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
238
tools/test_religion_diplomacy.py
Normal file
238
tools/test_religion_diplomacy.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for Religion and Diplomacy features.
|
||||||
|
|
||||||
|
Verifies that agents are spawned with diverse religions and factions,
|
||||||
|
and that the systems work correctly.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/test_religion_diplomacy.py [--steps 100]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from backend.config import get_config
|
||||||
|
from backend.core.engine import GameEngine
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_diversity(num_agents: int = 50, num_steps: int = 100):
|
||||||
|
"""Test that agents have diverse religions and factions."""
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" RELIGION & DIPLOMACY SYSTEM TEST")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Initialize engine
|
||||||
|
engine = GameEngine()
|
||||||
|
engine.initialize(num_agents)
|
||||||
|
|
||||||
|
# Analyze agent distribution
|
||||||
|
religion_counts = defaultdict(int)
|
||||||
|
faction_counts = defaultdict(int)
|
||||||
|
faith_levels = []
|
||||||
|
|
||||||
|
agents = engine.world.agents
|
||||||
|
|
||||||
|
print(f"\n📊 Initial Agent Distribution ({len(agents)} agents):")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
religion = agent.religion.religion.value
|
||||||
|
faction = agent.diplomacy.faction.value
|
||||||
|
religion_counts[religion] += 1
|
||||||
|
faction_counts[faction] += 1
|
||||||
|
faith_levels.append(agent.religion.faith)
|
||||||
|
|
||||||
|
print("\n🕯️ RELIGIONS:")
|
||||||
|
for religion, count in sorted(religion_counts.items(), key=lambda x: -x[1]):
|
||||||
|
pct = count / len(agents) * 100
|
||||||
|
bar = "█" * int(pct / 5)
|
||||||
|
print(f" {religion:12s}: {count:3d} ({pct:5.1f}%) {bar}")
|
||||||
|
|
||||||
|
print("\n⚔️ FACTIONS:")
|
||||||
|
for faction, count in sorted(faction_counts.items(), key=lambda x: -x[1]):
|
||||||
|
pct = count / len(agents) * 100
|
||||||
|
bar = "█" * int(pct / 5)
|
||||||
|
print(f" {faction:12s}: {count:3d} ({pct:5.1f}%) {bar}")
|
||||||
|
|
||||||
|
avg_faith = sum(faith_levels) / len(faith_levels) if faith_levels else 0
|
||||||
|
print(f"\n✨ Average Faith: {avg_faith:.1f}")
|
||||||
|
|
||||||
|
# Check for issues
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
atheist_pct = religion_counts.get("atheist", 0) / len(agents) * 100
|
||||||
|
if atheist_pct > 50:
|
||||||
|
issues.append(f"⚠️ Too many atheists: {atheist_pct:.1f}% (expected < 50%)")
|
||||||
|
|
||||||
|
neutral_pct = faction_counts.get("neutral", 0) / len(agents) * 100
|
||||||
|
if neutral_pct > 30:
|
||||||
|
issues.append(f"⚠️ Too many neutral faction: {neutral_pct:.1f}% (expected < 30%)")
|
||||||
|
|
||||||
|
if len(religion_counts) < 3:
|
||||||
|
issues.append(f"⚠️ Low religion diversity: only {len(religion_counts)} religions")
|
||||||
|
|
||||||
|
if len(faction_counts) < 3:
|
||||||
|
issues.append(f"⚠️ Low faction diversity: only {len(faction_counts)} factions")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
print("\n⚠️ ISSUES FOUND:")
|
||||||
|
for issue in issues:
|
||||||
|
print(f" {issue}")
|
||||||
|
else:
|
||||||
|
print("\n✅ Distribution looks good!")
|
||||||
|
|
||||||
|
# Run simulation to test mechanics
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(f" Running {num_steps} step simulation...")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Track events
|
||||||
|
religious_events = []
|
||||||
|
diplomatic_events = []
|
||||||
|
faith_changes = []
|
||||||
|
|
||||||
|
initial_faith = {a.id: a.religion.faith for a in agents}
|
||||||
|
|
||||||
|
for step in range(num_steps):
|
||||||
|
turn_log = engine.next_step()
|
||||||
|
|
||||||
|
# Collect events
|
||||||
|
religious_events.extend(turn_log.religious_events)
|
||||||
|
diplomatic_events.extend(turn_log.diplomatic_events)
|
||||||
|
|
||||||
|
if step % 20 == 19:
|
||||||
|
sys.stdout.write(f"\r Step {step + 1}/{num_steps}...")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
print(f"\r Completed {num_steps} steps! ")
|
||||||
|
|
||||||
|
# Final analysis
|
||||||
|
print("\n📈 SIMULATION RESULTS:")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
living_agents = engine.world.get_living_agents()
|
||||||
|
print(f" Living agents: {len(living_agents)}/{len(agents)}")
|
||||||
|
|
||||||
|
# Final faith levels
|
||||||
|
final_faith = [a.religion.faith for a in living_agents]
|
||||||
|
avg_final_faith = sum(final_faith) / len(final_faith) if final_faith else 0
|
||||||
|
print(f" Average faith: {avg_final_faith:.1f} (started: {avg_faith:.1f})")
|
||||||
|
|
||||||
|
# Events summary
|
||||||
|
print(f"\n Religious events: {len(religious_events)}")
|
||||||
|
if religious_events:
|
||||||
|
event_types = defaultdict(int)
|
||||||
|
for event in religious_events:
|
||||||
|
event_types[event.get("type", "unknown")] += 1
|
||||||
|
for event_type, count in sorted(event_types.items(), key=lambda x: -x[1])[:5]:
|
||||||
|
print(f" - {event_type}: {count}")
|
||||||
|
|
||||||
|
print(f"\n Diplomatic events: {len(diplomatic_events)}")
|
||||||
|
if diplomatic_events:
|
||||||
|
event_types = defaultdict(int)
|
||||||
|
for event in diplomatic_events:
|
||||||
|
event_types[event.get("type", "unknown")] += 1
|
||||||
|
for event_type, count in sorted(event_types.items(), key=lambda x: -x[1])[:5]:
|
||||||
|
print(f" - {event_type}: {count}")
|
||||||
|
|
||||||
|
# Check world state
|
||||||
|
stats = engine.world.get_statistics()
|
||||||
|
print(f"\n Total wars declared: {stats.get('total_wars', 0)}")
|
||||||
|
print(f" Total peace treaties: {stats.get('total_peace_treaties', 0)}")
|
||||||
|
print(f" Active wars: {stats.get('active_wars', [])}")
|
||||||
|
|
||||||
|
# Final religion/faction distribution
|
||||||
|
print("\n📊 Final Religion Distribution:")
|
||||||
|
final_religions = defaultdict(int)
|
||||||
|
for agent in living_agents:
|
||||||
|
final_religions[agent.religion.religion.value] += 1
|
||||||
|
|
||||||
|
for religion, count in sorted(final_religions.items(), key=lambda x: -x[1]):
|
||||||
|
pct = count / len(living_agents) * 100 if living_agents else 0
|
||||||
|
print(f" {religion:12s}: {count:3d} ({pct:5.1f}%)")
|
||||||
|
|
||||||
|
# Return metrics for optimization
|
||||||
|
return {
|
||||||
|
"initial_atheist_pct": atheist_pct,
|
||||||
|
"initial_neutral_pct": neutral_pct,
|
||||||
|
"religion_diversity": len(religion_counts),
|
||||||
|
"faction_diversity": len(faction_counts),
|
||||||
|
"avg_initial_faith": avg_faith,
|
||||||
|
"avg_final_faith": avg_final_faith,
|
||||||
|
"religious_events": len(religious_events),
|
||||||
|
"diplomatic_events": len(diplomatic_events),
|
||||||
|
"survival_rate": len(living_agents) / len(agents) if agents else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Test Religion & Diplomacy systems")
|
||||||
|
parser.add_argument("--agents", "-a", type=int, default=50, help="Number of agents")
|
||||||
|
parser.add_argument("--steps", "-s", type=int, default=100, help="Simulation steps")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
metrics = test_agent_diversity(args.agents, args.steps)
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" TEST COMPLETE")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Final verdict
|
||||||
|
score = 0
|
||||||
|
max_score = 6
|
||||||
|
|
||||||
|
if metrics["initial_atheist_pct"] < 30:
|
||||||
|
score += 1
|
||||||
|
print("✅ Atheist percentage is reasonable")
|
||||||
|
else:
|
||||||
|
print(f"❌ Too many atheists: {metrics['initial_atheist_pct']:.1f}%")
|
||||||
|
|
||||||
|
if metrics["initial_neutral_pct"] < 20:
|
||||||
|
score += 1
|
||||||
|
print("✅ Neutral faction percentage is reasonable")
|
||||||
|
else:
|
||||||
|
print(f"❌ Too many neutrals: {metrics['initial_neutral_pct']:.1f}%")
|
||||||
|
|
||||||
|
if metrics["religion_diversity"] >= 4:
|
||||||
|
score += 1
|
||||||
|
print(f"✅ Good religion diversity: {metrics['religion_diversity']} religions")
|
||||||
|
else:
|
||||||
|
print(f"❌ Low religion diversity: {metrics['religion_diversity']} religions")
|
||||||
|
|
||||||
|
if metrics["faction_diversity"] >= 4:
|
||||||
|
score += 1
|
||||||
|
print(f"✅ Good faction diversity: {metrics['faction_diversity']} factions")
|
||||||
|
else:
|
||||||
|
print(f"❌ Low faction diversity: {metrics['faction_diversity']} factions")
|
||||||
|
|
||||||
|
if metrics["religious_events"] > 0:
|
||||||
|
score += 1
|
||||||
|
print(f"✅ Religious events occurring: {metrics['religious_events']}")
|
||||||
|
else:
|
||||||
|
print("❌ No religious events")
|
||||||
|
|
||||||
|
if metrics["diplomatic_events"] > 0 or metrics["religious_events"] > 0:
|
||||||
|
score += 1
|
||||||
|
print(f"✅ Social dynamics active")
|
||||||
|
else:
|
||||||
|
print("❌ No social dynamics")
|
||||||
|
|
||||||
|
print(f"\n📊 Score: {score}/{max_score}")
|
||||||
|
|
||||||
|
if score < max_score:
|
||||||
|
print("\n💡 Consider adjusting config.json parameters for better diversity")
|
||||||
|
|
||||||
|
return score == max_score
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = main()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user