Religion and diplomacy systems + OIL #1

Open
elit3guzhva wants to merge 1 commits from war-and-peace into master
23 changed files with 5365 additions and 1580 deletions
Showing only changes of commit cfd6c87f86 - Show all commits

View File

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

View File

@ -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
@ -76,6 +98,15 @@ class ActionConfig:
gather_max: int = 5 gather_max: int = 5
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
@ -90,42 +121,59 @@ class WorldConfig:
# Agent configuration # Agent configuration
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

View File

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

View File

@ -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,11 +245,13 @@ 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."""
@ -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)

View File

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

View File

@ -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
@ -25,6 +26,20 @@ class ActionType(Enum):
BUILD_FIRE = "build_fire" # Consumes wood, provides heat BUILD_FIRE = "build_fire" # Consumes wood, provides heat
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
@ -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,
} }

View File

@ -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)
@ -105,6 +115,9 @@ class AgentStats:
# Clothes reduce heat loss by 50% # Clothes reduce heat loss by 50%
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."""
@ -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
View 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
View 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

View File

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

View File

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

View File

@ -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,11 +54,74 @@ 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:
@ -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)

View File

@ -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
@ -176,6 +204,10 @@ class VillageSimulationApp:
if not self.settings_renderer.visible: if not self.settings_renderer.visible:
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."""
@ -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
@ -292,12 +330,13 @@ class VillageSimulationApp:
self.state = self.client.get_state() self.state = self.client.get_state()
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(" ESC - Close panel / Quit") print(" F - Toggle fullscreen")
print(" ESC - Close panel / Quit")
print() print()
while self.running: while self.running:
@ -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]

View File

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

View File

@ -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
# Border if ratio < 0.25:
pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1) 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))
def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None: def _draw_status_bars(
"""Draw status bars below the agent.""" self,
agent: dict,
center_x: int,
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,170 +211,191 @@ 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
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( def _draw_religion_indicator(
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)
def _draw_war_indicator(self, agent: dict, center_x: int, center_y: int) -> None:
"""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)
text = self.small_font.render(result, True, (180, 180, 180)) if at_war:
text_rect = text.get_rect(center=(center_x, text_y)) # Red war indicator
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.15)
# Background for readability war_color = (int(200 * pulse), int(50 * pulse), int(50 * pulse))
bg_rect = text_rect.inflate(4, 2) pygame.draw.circle(
pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect) self.screen, war_color,
(center_x - 6, center_y - 6),
self.screen.blit(text, text_rect) 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):
# Get screen position from agent's current position living.append(agent)
# 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)
self._draw_status_bars(agent, screen_x, screen_y, agent_size) if self.detail_level >= 1:
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,
@ -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))

View File

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

View File

@ -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
# Handle buttons # 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 )
if clip_rect.colliderect(adjusted_rect):
# Temporarily move slider for drawing
original_y = slider.rect.y
slider.rect.y = adjusted_rect.y
slider.draw(self.screen)
slider.rect.y = original_y
# Draw scroll indicator # Draw sliders for current section
section = SECTION_ORDER[self.current_section]
section_color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
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

View File

@ -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)
# Scrolling state for right panel
self.scroll_offset = 0
self.max_scroll = 0
self.scroll_dragging = False
# Animation
self.animation_tick = 0
def _draw_panel(self, rect: pygame.Rect, title: Optional[str] = None) -> None: def _draw_panel_bg(
"""Draw a panel background.""" self,
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect) rect: pygame.Rect,
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1) 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
# ═══════════════════════════════════════════════════════════════
# DIPLOMACY SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_diplomacy_section(state, content_x, y, content_width)
y += 15
# ═══════════════════════════════════════════════════════════════
# MARKET SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_market_section(state, content_x, y, content_width)
def _draw_statistics_section(self, state: "SimulationState", x: int, y: int) -> int: def _draw_stats_section(
"""Draw the statistics section.""" self, state: "SimulationState", x: int, y: int, width: int
# Title ) -> int:
title = self.title_font.render("Statistics", True, Colors.TEXT_PRIMARY) """Draw statistics section."""
self.screen.blit(title, (x, y)) # Section header
y += 30 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))
self.screen.blit(text, (x, y)) y += 16
y += 16
# Average faith
for prof, count in professions.items(): avg_faith = state.get_avg_faith()
text = self.small_font.render(f" {prof}: {count}", True, Colors.TEXT_SECONDARY) 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 += 14 y += 16
return y return y
def _draw_market_section(self, state: "SimulationState", x: int, y: int) -> int: def _draw_factions_section(
"""Draw the market section.""" self, state: "SimulationState", x: int, y: int, width: int
# Title ) -> int:
title = self.title_font.render("Market", True, Colors.TEXT_PRIMARY) """Draw factions section with distribution bars."""
self.screen.blit(title, (x, y)) # Section header
y += 30 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
order_count = len(state.market_orders) order_count = len(state.market_orders)
text = self.small_font.render(f"Active Orders: {order_count}", True, Colors.TEXT_SECONDARY) text = self.small_font.render(
f"Active Orders: {order_count}",
True, Colors.TEXT_SECONDARY
)
self.screen.blit(text, (x, y)) self.screen.blit(text, (x, y))
y += 20 y += 16
# Price summary for each resource with available stock # Price summary (show resources with stock)
prices = state.market_prices prices = state.market_prices
for resource, data in prices.items(): shown = 0
if data.get("total_available", 0) > 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", "?") price = data.get("lowest_price", "?")
qty = data.get("total_available", 0)
text = self.small_font.render( # Resource color coding
f"{resource}: {qty}x @ {price}c", if "oil" in resource.lower() or "fuel" in resource.lower():
True, res_color = (180, 160, 100)
Colors.TEXT_SECONDARY, 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)) self.screen.blit(text, (x, y))
y += 16 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))
y += 14
return y return y
def _draw_controls_help(self, x: int, y: int) -> None: def draw_bottom_bar(self, state: "SimulationState") -> None:
"""Draw controls help at bottom of panel.""" """Draw bottom information bar with event log."""
bar_y = self.screen.get_height() - self.bottom_panel_height
rect = pygame.Rect(0, bar_y, self.screen.get_width(), self.bottom_panel_height)
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, 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",
"ESC - Quit",
]
for control in controls: # Show recent religious events
text = self.small_font.render(control, True, Colors.TEXT_SECONDARY) for event in state.religious_events[:2]:
event_type = event.get("type", "")
desc = event.get("description", event_type)[:30]
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
View 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
View 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()

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