Enhance Village Simulation with religion and diplomacy systems, introducing diverse agent beliefs and faction dynamics. Updated configuration parameters for agent stats, resource decay, and economic interactions. Implemented new actions related to religion and diplomacy, including praying, preaching, and negotiating. Improved UI for displaying religious and diplomatic information, and added tools for testing and optimizing balance in these new systems.

This commit is contained in:
elit3guzhva 2026-01-19 02:14:46 +03:00
parent 1423fc0dc9
commit cfd6c87f86
23 changed files with 5365 additions and 1580 deletions

View File

@ -28,10 +28,12 @@ class StatsSchema(BaseModel):
hunger: int
thirst: int
heat: int
faith: int = 50
max_energy: int
max_hunger: int
max_thirst: int
max_heat: int
max_faith: int = 100
class AgentActionSchema(BaseModel):
@ -44,6 +46,28 @@ class AgentActionSchema(BaseModel):
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):
"""Schema for agent data."""
id: str
@ -58,6 +82,9 @@ class AgentResponse(BaseModel):
can_act: bool
current_action: AgentActionSchema
last_action_result: str
# Religion and diplomacy
religion: Optional[ReligionSchema] = None
diplomacy: Optional[DiplomacySchema] = None
# ============== Market Schemas ==============

View File

@ -12,41 +12,49 @@ class AgentStatsConfig:
# Maximum values
max_energy: int = 50
max_hunger: int = 100
max_thirst: int = 100 # Increased from 50 to give more buffer
max_thirst: int = 100
max_heat: int = 100
max_faith: int = 100 # NEW: Religious faith level
# Starting values
start_energy: int = 50
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_faith: int = 50 # NEW: Start with moderate faith
# Decay rates per turn
energy_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
faith_decay: int = 1 # NEW: Faith decays slowly without religious activity
# Thresholds
critical_threshold: float = 0.25 # 25% triggers survival mode
low_energy_threshold: int = 15 # Minimum energy to work
critical_threshold: float = 0.25
low_energy_threshold: int = 15
@dataclass
class ResourceConfig:
"""Configuration for resource properties."""
# 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
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
meat_hunger: int = 30
meat_energy: int = 5
berries_hunger: int = 8 # Increased from 5
berries_thirst: int = 3 # Increased from 2
water_thirst: int = 50 # Increased from 40 for better thirst recovery
fire_heat: int = 15 # Increased from 10
berries_hunger: int = 8
berries_thirst: int = 3
water_thirst: int = 50
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
@ -63,9 +71,23 @@ class ActionConfig:
build_fire_energy: int = -5
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)
hunt_success: float = 0.7
chop_wood_success: float = 0.9
drill_oil_success: float = 0.6 # NEW: Harder to extract oil
# Output quantities
hunt_meat_min: int = 1
@ -77,6 +99,15 @@ class ActionConfig:
chop_wood_min: int = 1
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
class WorldConfig:
@ -91,41 +122,58 @@ class WorldConfig:
inventory_slots: int = 10
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
class MarketConfig:
"""Configuration for market behavior."""
turns_before_discount: int = 3
discount_rate: float = 0.15 # 15% discount after waiting
base_price_multiplier: float = 1.2 # Markup over production cost
discount_rate: float = 0.15
base_price_multiplier: float = 1.2
@dataclass
class EconomyConfig:
"""Configuration for economic behavior and agent trading.
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
"""Configuration for economic behavior and agent trading."""
energy_to_money_ratio: float = 1.5
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
# Minimum wealth target - agents want at least this much money
min_wealth_target: int = 50
max_price_markup: float = 2.0
min_price_discount: float = 0.5
# Price adjustment limits
max_price_markup: float = 2.0 # Maximum price = 2x base value
min_price_discount: float = 0.5 # Minimum price = 50% of base value
# NEW: Oil economy
oil_base_price: int = 25 # Oil is valuable
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
@ -137,9 +185,11 @@ class SimulationConfig:
world: WorldConfig = field(default_factory=WorldConfig)
market: MarketConfig = field(default_factory=MarketConfig)
economy: EconomyConfig = field(default_factory=EconomyConfig)
religion: ReligionConfig = field(default_factory=ReligionConfig) # NEW
diplomacy: DiplomacyConfig = field(default_factory=DiplomacyConfig) # NEW
# Simulation control
auto_step_interval: float = 1.0 # Seconds between auto steps
auto_step_interval: float = 1.0
def to_dict(self) -> dict:
"""Convert to dictionary."""
@ -150,6 +200,8 @@ class SimulationConfig:
"world": asdict(self.world),
"market": asdict(self.market),
"economy": asdict(self.economy),
"religion": asdict(self.religion),
"diplomacy": asdict(self.diplomacy),
"auto_step_interval": self.auto_step_interval,
}
@ -163,6 +215,8 @@ class SimulationConfig:
world=WorldConfig(**data.get("world", {})),
market=MarketConfig(**data.get("market", {})),
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),
)
@ -179,7 +233,7 @@ class SimulationConfig:
data = json.load(f)
return cls.from_dict(data)
except FileNotFoundError:
return cls() # Return defaults if file not found
return cls()
# Global configuration instance
@ -187,10 +241,7 @@ _config: Optional[SimulationConfig] = None
def get_config() -> SimulationConfig:
"""Get the global configuration instance.
Loads from config.json if not already loaded.
"""
"""Get the global configuration instance."""
global _config
if _config is None:
_config = load_config()
@ -202,8 +253,6 @@ def load_config(path: str = "config.json") -> SimulationConfig:
try:
config_path = Path(path)
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
config_path = workspace_root / path
@ -214,7 +263,7 @@ def load_config(path: str = "config.json") -> SimulationConfig:
except (FileNotFoundError, json.JSONDecodeError) as 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:
@ -252,4 +301,3 @@ def _reset_all_caches() -> None:
reset_resource_cache()
except ImportError:
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 threading
@ -9,8 +15,12 @@ from typing import Optional
from backend.domain.agent import Agent
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.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.market import OrderBook
from backend.core.ai import get_ai_decision, AIDecision
@ -20,8 +30,8 @@ from backend.config import get_config
class SimulationMode(Enum):
"""Simulation run mode."""
MANUAL = "manual" # Wait for explicit next_step call
AUTO = "auto" # Run automatically with timer
MANUAL = "manual"
AUTO = "auto"
@dataclass
@ -31,6 +41,8 @@ class TurnLog:
agent_actions: list[dict] = field(default_factory=list)
deaths: list[str] = 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:
return {
@ -38,6 +50,8 @@ class TurnLog:
"agent_actions": self.agent_actions,
"deaths": self.deaths,
"trades": self.trades,
"religious_events": self.religious_events,
"diplomatic_events": self.diplomatic_events,
}
@ -60,7 +74,6 @@ class GameEngine:
self.market = OrderBook()
self.mode = SimulationMode.MANUAL
self.is_running = False
# Load auto_step_interval from config
self.auto_step_interval = get_config().auto_step_interval
self._auto_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
@ -70,9 +83,11 @@ class GameEngine:
def reset(self, config: Optional[WorldConfig] = None) -> None:
"""Reset the simulation to initial state."""
# Stop auto mode if running
self._stop_auto_mode()
# Reset faction relations
reset_faction_relations()
if config:
self.world = World(config=config)
else:
@ -80,7 +95,6 @@ class GameEngine:
self.market = OrderBook()
self.turn_logs = []
# Reset and start new logging session
self.logger = reset_simulation_logger()
sim_config = get_config()
self.logger.start_session(sim_config.to_dict())
@ -89,18 +103,15 @@ class GameEngine:
self.is_running = True
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:
self.world.config.initial_agents = num_agents
# Otherwise use the value already loaded from config.json
self.world.initialize()
# Start logging session
self.logger = reset_simulation_logger()
sim_config = get_config()
self.logger.start_session(sim_config.to_dict())
@ -115,7 +126,6 @@ class GameEngine:
turn_log = TurnLog(turn=self.world.current_turn + 1)
current_turn = self.world.current_turn + 1
# Start logging this turn
self.logger.start_turn(
turn=current_turn,
day=self.world.current_day,
@ -123,16 +133,14 @@ class GameEngine:
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()]
# 0. Remove corpses from previous turn (agents who died last turn)
# Remove old corpses
self._remove_old_corpses(current_turn)
# 1. Collect AI decisions for all living agents (not corpses)
# Collect AI decisions
decisions: list[tuple[Agent, AIDecision]] = []
for agent in self.world.get_living_agents():
# Log agent state before
self.logger.log_agent_before(
agent_id=agent.id,
agent_name=agent.name,
@ -144,27 +152,24 @@ class GameEngine:
)
if self.world.is_night():
# Force sleep at night
decision = AIDecision(
action=ActionType.SLEEP,
reason="Night time: sleeping",
)
else:
# Pass time info so AI can prepare for night
decision = get_ai_decision(
agent,
self.market,
step_in_day=self.world.step_in_day,
day_steps=self.world.config.day_steps,
current_turn=current_turn,
world=self.world,
)
decisions.append((agent, decision))
# Log decision
self.logger.log_agent_decision(agent.id, decision.to_dict())
# 2. Calculate movement targets and move agents
# Calculate movement
for agent, decision in decisions:
action_name = decision.action.value
agent.set_action(
@ -173,14 +178,14 @@ class GameEngine:
world_height=self.world.config.height,
message=decision.reason,
target_resource=decision.target_resource.value if decision.target_resource else None,
target_agent=decision.target_agent_id,
)
agent.update_movement()
# 3. Execute all actions and update action indicators with results
# Execute actions
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:
agent.complete_action(result.success, result.message)
@ -191,7 +196,6 @@ class GameEngine:
"result": result.to_dict() if result else None,
})
# Log agent state after action
self.logger.log_agent_after(
agent_id=agent.id,
stats=agent.stats.to_dict(),
@ -201,40 +205,35 @@ class GameEngine:
action_result=result.to_dict() if result else {},
)
# 4. Resolve pending market orders (price updates)
# Update market prices
self.market.update_prices(current_turn)
# Log market state after
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)
# 5. Apply passive decay to all living agents
# Apply passive decay
for agent in self.world.get_living_agents():
agent.apply_passive_decay()
# 6. Decay resources in inventories
# Decay resources
for agent in self.world.get_living_agents():
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)
for dead_agent in newly_dead:
cause = dead_agent.death_reason
self.logger.log_death(dead_agent.name, cause)
# Cancel their market orders immediately
self.market.cancel_seller_orders(dead_agent.id)
turn_log.deaths = [a.name for a in newly_dead]
# Log statistics
self.logger.log_statistics(self.world.get_statistics())
# End turn logging
self.logger.end_turn()
# 8. Advance time
# 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:
self.is_running = False
self.logger.close()
@ -243,14 +242,12 @@ class GameEngine:
return turn_log
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 = []
for agent in self.world.agents:
if not agent.is_alive() and not agent.is_corpse():
# Agent just died this turn
cause = agent.stats.get_critical_stat() or "unknown"
agent.mark_dead(current_turn, cause)
# Clear their action to show death state
agent.current_action.action_type = "dead"
agent.current_action.message = f"Died: {cause}"
newly_dead.append(agent)
@ -261,7 +258,6 @@ class GameEngine:
to_remove = []
for agent in self.world.agents:
if agent.is_corpse() and agent.death_turn < current_turn:
# Corpse has been visible for one turn, remove it
to_remove.append(agent)
for agent in to_remove:
@ -270,12 +266,12 @@ class GameEngine:
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."""
action = decision.action
config = ACTION_CONFIG[action]
# Handle different action types
# Basic actions
if action == ActionType.SLEEP:
agent.restore_energy(config.energy_cost)
return ActionResult(
@ -309,8 +305,6 @@ class GameEngine:
agent.remove_from_inventory(ResourceType.WOOD, 1)
if not agent.spend_energy(abs(config.energy_cost)):
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()
agent.apply_heat(fire_heat)
return ActionResult(
@ -322,25 +316,56 @@ class GameEngine:
)
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:
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,
ActionType.GET_WATER, ActionType.WEAVE]:
ActionType.GET_WATER, ActionType.WEAVE, ActionType.DRILL_OIL,
ActionType.REFINE]:
return self._execute_work(agent, action, config)
return ActionResult(action_type=action, success=False, message="Unknown action")
def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult:
"""Execute a work action (hunting, gathering, 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
"""Execute a work action (hunting, gathering, drilling, etc.)."""
energy_cost = abs(config.energy_cost)
if not agent.spend_energy(energy_cost):
return ActionResult(
@ -349,10 +374,9 @@ class GameEngine:
message="Not enough energy",
)
# Check required materials
if config.requires_resource:
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(
action_type=action,
success=False,
@ -360,19 +384,22 @@ class GameEngine:
)
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_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0
skill_modifier = get_action_skill_modifier(skill_value)
# Check success chance (modified by skill)
# Higher skill = higher effective success chance
effective_success_chance = min(0.98, config.success_chance * skill_modifier)
# Get religion bonus
religion_bonus = get_religion_action_bonus(agent.religion.religion, action.value)
# 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:
# Record action attempt (skill still improves on failure, just less)
agent.record_action(action.value)
if skill_name:
agent.skills.improve(skill_name, 0.005) # Small improvement on failure
agent.skills.improve(skill_name, 0.005)
return ActionResult(
action_type=action,
success=False,
@ -380,13 +407,11 @@ class GameEngine:
message="Action failed",
)
# Generate output (modified by skill for quantity)
resources_gained = []
if config.output_resource:
# Skill affects output quantity
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:
resource = Resource(
@ -402,10 +427,9 @@ class GameEngine:
created_turn=self.world.current_turn,
))
# Secondary output (e.g., hide from hunting) - also affected by skill
if config.secondary_output:
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:
resource = Resource(
type=config.secondary_output,
@ -420,12 +444,10 @@ class GameEngine:
created_turn=self.world.current_turn,
))
# Record action and improve skill
agent.record_action(action.value)
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)
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]:
"""Get the skill name that affects a given action."""
"""Get the skill name for an action."""
skill_map = {
ActionType.HUNT: "hunting",
ActionType.GATHER: "gathering",
ActionType.CHOP_WOOD: "woodcutting",
ActionType.WEAVE: "crafting",
ActionType.DRILL_OIL: "gathering", # Use gathering skill for now
ActionType.REFINE: "crafting",
}
return skill_map.get(action)
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
"""Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades.
def _execute_pray(self, agent: Agent, config, turn_log: TurnLog) -> ActionResult:
"""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]
# Handle price adjustments (no energy cost)
if decision.adjust_order_id and decision.new_price is not None:
return self._execute_price_adjustment(agent, decision)
# Handle multi-item trades
if decision.trade_items:
return self._execute_multi_buy(agent, decision)
if decision.order_id:
# Buying single item from market
result = self.market.execute_buy(
buyer_id=agent.id,
order_id=decision.order_id,
@ -472,10 +701,8 @@ class GameEngine:
)
if result.success:
# Log the trade
self.logger.log_trade(result.to_dict())
# Record sale for price history tracking
self.market._record_sale(
result.resource_type,
result.total_paid // result.quantity,
@ -483,10 +710,8 @@ class GameEngine:
self.world.current_turn,
)
# Deduct money from buyer
agent.money -= result.total_paid
# Add resources to buyer
resource = Resource(
type=result.resource_type,
quantity=result.quantity,
@ -494,18 +719,25 @@ class GameEngine:
)
agent.add_to_inventory(resource)
# Add money to seller and record their trade
seller = self.world.get_agent(result.seller_id)
if seller:
seller.money += 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))
# Record buyer's trade and improve skill
agent.record_action("trade")
agent.skills.improve("trading", 0.01) # Buyer skill improves less
agent.skills.improve("trading", 0.01)
return ActionResult(
action_type=ActionType.TRADE,
@ -522,7 +754,6 @@ class GameEngine:
)
elif decision.target_resource and decision.quantity > 0:
# Selling to market (listing)
if agent.has_resource(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.record_action("trade") # Track listing action
agent.record_action("trade")
return ActionResult(
action_type=ActionType.TRADE,
@ -557,7 +788,7 @@ class GameEngine:
)
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(
order_id=decision.adjust_order_id,
seller_id=agent.id,
@ -569,8 +800,8 @@ class GameEngine:
return ActionResult(
action_type=ActionType.TRADE,
success=True,
energy_spent=0, # Price adjustments are free
message=f"Adjusted {decision.target_resource.value} price to {decision.new_price}c",
energy_spent=0,
message=f"Adjusted price to {decision.new_price}c",
)
else:
return ActionResult(
@ -583,17 +814,13 @@ class GameEngine:
"""Execute a multi-item buy trade."""
config = ACTION_CONFIG[ActionType.TRADE]
# Build list of purchases
purchases = [(item.order_id, item.quantity) for item in decision.trade_items]
# Execute all purchases
results = self.market.execute_multi_buy(
buyer_id=agent.id,
purchases=purchases,
buyer_money=agent.money,
)
# Process results
total_paid = 0
resources_gained = []
items_bought = []
@ -604,7 +831,6 @@ class GameEngine:
agent.money -= result.total_paid
total_paid += result.total_paid
# Record sale for price history
self.market._record_sale(
result.resource_type,
result.total_paid // result.quantity,
@ -621,7 +847,6 @@ class GameEngine:
resources_gained.append(resource)
items_bought.append(f"{result.quantity} {result.resource_type.value}")
# Add money to seller
seller = self.world.get_agent(result.seller_id)
if seller:
seller.money += result.total_paid
@ -687,7 +912,6 @@ class GameEngine:
}
# Global engine instance
def get_engine() -> GameEngine:
"""Get the global game engine instance."""
return GameEngine()

View File

@ -3,6 +3,9 @@
The world spawns diverse agents with varied personality traits,
skills, and starting conditions to create emergent professions
and class inequality.
NEW: World now supports religion and faction systems for realistic
social dynamics including religious diversity and geopolitical factions.
"""
import random
@ -15,6 +18,13 @@ from backend.domain.personality import (
PersonalityTraits, 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):
@ -31,16 +41,14 @@ def _get_world_config_from_file():
@dataclass
class WorldConfig:
"""Configuration for the world.
Default values are loaded from config.json via create_world_config().
These hardcoded defaults are only fallbacks.
"""
"""Configuration for the world."""
width: int = 25
height: int = 25
initial_agents: int = 25
day_steps: int = 10
night_steps: int = 1
oil_fields_count: int = 3 # NEW
temple_count: int = 2 # NEW
def create_world_config() -> WorldConfig:
@ -52,9 +60,21 @@ def create_world_config() -> WorldConfig:
initial_agents=cfg.initial_agents,
day_steps=cfg.day_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
class World:
"""Container for all entities in the simulation."""
@ -65,9 +85,46 @@ class World:
step_in_day: int = 0
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
total_agents_spawned: 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(
self,
@ -76,6 +133,8 @@ class World:
position: Optional[Position] = None,
archetype: Optional[str] = None,
starting_money: Optional[int] = None,
religion: Optional[ReligiousBeliefs] = None,
faction: Optional[AgentDiplomacy] = None,
) -> Agent:
"""Spawn a new agent in the world with unique personality.
@ -85,6 +144,8 @@ class World:
position: Starting position (random if None)
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
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:
position = Position(
@ -96,25 +157,38 @@ class World:
personality = generate_random_personality(archetype)
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
# Some agents start with more, some with less
if starting_money is None:
from backend.config import get_config
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)
# Traders start with more money (their capital)
if personality.trade_preference > 1.3:
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)
agent = Agent(
name=name or f"Villager_{self.total_agents_spawned + 1}",
profession=Profession.VILLAGER, # Will be updated based on personality
profession=Profession.VILLAGER,
position=position,
personality=personality,
skills=skills,
religion=religion,
diplomacy=faction,
money=starting_money,
)
@ -129,12 +203,35 @@ class World:
return agent
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]:
"""Remove all dead agents from the world. Returns list of removed agents.
Note: This is now handled by the engine's corpse system for visualization.
"""
"""Remove all dead agents from the world."""
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
def advance_time(self) -> None:
@ -148,12 +245,14 @@ class World:
self.step_in_day = 1
self.current_day += 1
# Determine time of day
if self.step_in_day <= self.config.day_steps:
self.time_of_day = TimeOfDay.DAY
else:
self.time_of_day = TimeOfDay.NIGHT
# Update faction relations each turn
self.faction_relations.update_turn(self.current_turn)
def is_night(self) -> bool:
"""Check if it's currently night."""
return self.time_of_day == TimeOfDay.NIGHT
@ -167,13 +266,25 @@ class World:
living = self.get_living_agents()
total_money = sum(a.money for a in living)
# Count emergent professions (updated based on current skills)
# Count emergent professions
profession_counts = {}
for agent in living:
agent._update_profession() # Update based on current state
agent._update_profession()
prof = agent.profession.value
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
if living:
moneys = sorted([a.money for a in living])
@ -182,15 +293,21 @@ class World:
richest = moneys[-1] 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)
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
# Average faith
avg_faith = sum(a.stats.faith for a in living) / len(living)
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 {
"current_turn": self.current_turn,
@ -202,12 +319,20 @@ class World:
"total_agents_died": self.total_agents_died,
"total_money_in_circulation": total_money,
"professions": profession_counts,
# Wealth inequality metrics
# Wealth metrics
"avg_money": round(avg_money, 1),
"median_money": median_money,
"richest_agent": richest,
"poorest_agent": poorest,
"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:
@ -220,21 +345,34 @@ class World:
"world_size": {"width": self.config.width, "height": self.config.height},
"agents": [a.to_dict() for a in self.agents],
"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:
"""Initialize the world with diverse starting agents.
Creates a mix of agent archetypes to seed profession diversity:
- Some hunters (risk-takers who hunt)
- Some gatherers (cautious resource collectors)
- Some traders (market-focused wealth builders)
- Some generalists (balanced approach)
Creates a mix of agent archetypes to seed profession diversity.
Now also seeds religious and faction diversity.
"""
# Reset faction relations
self.faction_relations = reset_faction_relations()
# Generate special locations
self._generate_locations()
n = self.config.initial_agents
# Distribute archetypes for diversity
# ~15% hunters, ~15% gatherers, ~15% traders, ~10% woodcutters, 45% random
archetypes = (
["hunter"] * max(1, n // 7) +
["gatherer"] * max(1, n // 7) +
@ -242,13 +380,33 @@ class World:
["woodcutter"] * max(1, n // 10)
)
# Fill remaining slots with random (no archetype)
while len(archetypes) < n:
archetypes.append(None)
# Shuffle to randomize positions
random.shuffle(archetypes)
for archetype in archetypes:
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 .action import ActionType, ActionConfig, ActionResult, ACTION_CONFIG
from .agent import Agent, AgentStats, Position
from .religion import ReligionType, ReligiousBeliefs
from .diplomacy import FactionType, AgentDiplomacy, FactionRelations
__all__ = [
"ResourceType",
@ -15,5 +17,11 @@ __all__ = [
"Agent",
"AgentStats",
"Position",
# Religion
"ReligionType",
"ReligiousBeliefs",
# Diplomacy
"FactionType",
"AgentDiplomacy",
"FactionRelations",
]

View File

@ -15,6 +15,7 @@ if TYPE_CHECKING:
class ActionType(Enum):
"""Types of actions an agent can perform."""
# Basic survival actions
SLEEP = "sleep" # Night action - restores energy
REST = "rest" # Day action - restores some energy
HUNT = "hunt" # Produces meat and hide
@ -26,6 +27,20 @@ class ActionType(Enum):
TRADE = "trade" # Market interaction
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
class ActionConfig:
@ -40,14 +55,13 @@ class ActionConfig:
secondary_max: int = 0
requires_resource: Optional[ResourceType] = None
requires_quantity: int = 0
# NEW: Faith effects
faith_gain: int = 0
faith_spread: int = 0
def get_action_config() -> dict[ActionType, ActionConfig]:
"""Get action configurations from the global config.
This function dynamically builds ACTION_CONFIG from config.json values.
"""
# Import here to avoid circular imports
"""Get action configurations from the global config."""
from backend.config import get_config
config = get_config()
@ -55,10 +69,10 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
return {
ActionType.SLEEP: ActionConfig(
energy_cost=actions.sleep_energy, # Restores energy
energy_cost=actions.sleep_energy,
),
ActionType.REST: ActionConfig(
energy_cost=actions.rest_energy, # Restores some energy
energy_cost=actions.rest_energy,
),
ActionType.HUNT: ActionConfig(
energy_cost=actions.hunt_energy,
@ -112,6 +126,53 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
ActionType.CONSUME: ActionConfig(
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
# For backwards compatibility - this is a property-like access
# that returns fresh config each time (use get_cached_action_config for performance)
class _ActionConfigAccessor:
"""Accessor class that provides dict-like access to action configs."""
@ -161,6 +220,21 @@ class _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
class ActionResult:
"""Result of executing an action."""
@ -170,8 +244,14 @@ class ActionResult:
resources_gained: list = field(default_factory=list)
resources_consumed: list = field(default_factory=list)
heat_gained: int = 0
faith_gained: int = 0 # NEW
relation_change: int = 0 # NEW
message: str = ""
# NEW: Diplomatic effects
target_faction: Optional[str] = None
diplomatic_effect: Optional[str] = None # "war", "peace", "improved", "degraded"
def to_dict(self) -> dict:
"""Convert to dictionary for API serialization."""
return {
@ -187,5 +267,9 @@ class ActionResult:
for r in self.resources_consumed
],
"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,
}

View File

@ -3,6 +3,9 @@
Agent stats are loaded dynamically from the global config.
Each agent now has unique personality traits and skills that create
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
@ -17,6 +20,8 @@ from .personality import (
PersonalityTraits, Skills, ProfessionType,
determine_profession
)
from .religion import ReligiousBeliefs
from .diplomacy import AgentDiplomacy
def _get_agent_stats_config():
@ -33,6 +38,8 @@ class Profession(Enum):
WOODCUTTER = "woodcutter"
TRADER = "trader"
CRAFTER = "crafter"
OIL_WORKER = "oil_worker" # NEW: Oil industry worker
PRIEST = "priest" # NEW: Religious leader
@dataclass
@ -80,18 +87,21 @@ class AgentStats:
hunger: int = field(default=80)
thirst: int = field(default=70)
heat: int = field(default=100)
faith: int = field(default=50) # NEW: Religious faith level
# Maximum values - loaded from config
MAX_ENERGY: int = field(default=50)
MAX_HUNGER: int = field(default=100)
MAX_THIRST: 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
ENERGY_DECAY: int = field(default=1)
HUNGER_DECAY: int = field(default=2)
THIRST_DECAY: int = field(default=3)
HEAT_DECAY: int = field(default=2)
FAITH_DECAY: int = field(default=1) # NEW
# Critical threshold - loaded from config
CRITICAL_THRESHOLD: float = field(default=0.25)
@ -106,6 +116,9 @@ class AgentStats:
heat_decay = self.HEAT_DECAY // 2 if has_clothes else self.HEAT_DECAY
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:
"""Check if any vital stat is below critical threshold."""
threshold_hunger = int(self.MAX_HUNGER * self.CRITICAL_THRESHOLD)
@ -135,16 +148,31 @@ class AgentStats:
"""Check if agent has enough energy to perform an action."""
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:
return {
"energy": self.energy,
"hunger": self.hunger,
"thirst": self.thirst,
"heat": self.heat,
"faith": self.faith,
"max_energy": self.MAX_ENERGY,
"max_hunger": self.MAX_HUNGER,
"max_thirst": self.MAX_THIRST,
"max_heat": self.MAX_HEAT,
"max_faith": self.MAX_FAITH,
}
@ -156,14 +184,17 @@ def create_agent_stats() -> AgentStats:
hunger=config.start_hunger,
thirst=config.start_thirst,
heat=config.start_heat,
faith=getattr(config, 'start_faith', 50),
MAX_ENERGY=config.max_energy,
MAX_HUNGER=config.max_hunger,
MAX_THIRST=config.max_thirst,
MAX_HEAT=config.max_heat,
MAX_FAITH=getattr(config, 'max_faith', 100),
ENERGY_DECAY=config.energy_decay,
HUNGER_DECAY=config.hunger_decay,
THIRST_DECAY=config.thirst_decay,
HEAT_DECAY=config.heat_decay,
FAITH_DECAY=getattr(config, 'faith_decay', 1),
CRITICAL_THRESHOLD=config.critical_threshold,
)
@ -171,9 +202,10 @@ def create_agent_stats() -> AgentStats:
@dataclass
class AgentAction:
"""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_resource: Optional[str] = None
target_agent: Optional[str] = None # NEW: For diplomatic/religious actions
progress: float = 0.0 # 0.0 to 1.0
is_moving: bool = False
message: str = ""
@ -183,6 +215,7 @@ class AgentAction:
"action_type": self.action_type,
"target_position": self.target_position.to_dict() if self.target_position else None,
"target_resource": self.target_resource,
"target_agent": self.target_agent,
"progress": round(self.progress, 2),
"is_moving": self.is_moving,
"message": self.message,
@ -191,16 +224,27 @@ class AgentAction:
# Action location mappings (relative positions on the map for each action type)
ACTION_LOCATIONS = {
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)}, # Right side (forest)
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)}, # Left side (bushes)
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)}, # Far right (forest)
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)}, # Far left (river)
"weave": {"zone": "village", "offset_range": (0.4, 0.6)}, # Center (village)
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)},
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)},
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)},
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)},
"weave": {"zone": "village", "offset_range": (0.4, 0.6)},
"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)},
"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.
Each agent now has unique personality traits and skills that create
emergent behaviors and professions.
NEW: Agents now have religious beliefs and faction membership.
"""
id: str = field(default_factory=lambda: str(uuid4())[:8])
name: str = ""
profession: Profession = Profession.VILLAGER # Now derived from personality/skills
profession: Profession = Profession.VILLAGER
position: Position = field(default_factory=Position)
stats: AgentStats = field(default_factory=create_agent_stats)
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)
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
home_position: Position = field(default_factory=Position)
current_action: AgentAction = field(default_factory=AgentAction)
last_action_result: str = ""
# Death tracking for corpse visualization
death_turn: int = -1 # Turn when agent died, -1 if alive
death_reason: str = "" # Cause of death
death_turn: int = -1
death_reason: str = ""
# Statistics tracking for profession determination
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_money_earned: int = 0
# Configuration - loaded from config
INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value
MOVE_SPEED: float = 0.8 # Grid cells per turn
INVENTORY_SLOTS: int = field(default=-1)
MOVE_SPEED: float = 0.8
def __post_init__(self):
if not self.name:
self.name = f"Agent_{self.id}"
# Set home position to initial position
self.home_position = self.position.copy()
# Load config values if defaults were used
config = _get_world_config()
if self.money == -1:
self.money = config.starting_money
if self.INVENTORY_SLOTS == -1:
self.INVENTORY_SLOTS = config.inventory_slots
# Update profession based on personality and skills
self._update_profession()
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)
profession_map = {
ProfessionType.HUNTER: Profession.HUNTER,
@ -298,7 +361,7 @@ class Agent:
)
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
def can_act(self) -> bool:
@ -309,6 +372,14 @@ class Agent:
"""Check if agent has clothes equipped."""
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:
"""Get remaining inventory slots."""
total_items = sum(r.quantity for r in self.inventory)
@ -325,22 +396,20 @@ class Agent:
world_height: int,
message: str = "",
target_resource: Optional[str] = None,
target_agent: Optional[str] = None,
) -> None:
"""Set the current action and calculate target position."""
location = ACTION_LOCATIONS.get(action_type, {"zone": "current", "offset_range": (0, 0)})
if location["zone"] == "current":
# Stay in place
target = self.position.copy()
is_moving = False
else:
# Calculate target position based on action zone
offset_range = location["offset_range"]
offset_min = float(offset_range[0])
offset_max = float(offset_range[1])
offset_min = float(offset_range[0]) if offset_range else 0.0
offset_max = float(offset_range[1]) if offset_range else 0.0
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 = max(0.5, min(world_height - 0.5, target_y))
@ -351,6 +420,7 @@ class Agent:
action_type=action_type,
target_position=target,
target_resource=target_resource,
target_agent=target_agent,
progress=0.0,
is_moving=is_moving,
message=message,
@ -365,7 +435,7 @@ class Agent:
)
if reached:
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:
"""Mark current action as complete."""
@ -382,13 +452,11 @@ class Agent:
quantity_to_add = min(resource.quantity, space)
# Try to stack with existing resource of same type
for existing in self.inventory:
if existing.type == resource.type:
existing.quantity += quantity_to_add
return quantity_to_add
# Add as new stack
new_resource = Resource(
type=resource.type,
quantity=quantity_to_add,
@ -452,7 +520,7 @@ class Agent:
return True
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)
def restore_energy(self, amount: int) -> None:
@ -466,8 +534,13 @@ class Agent:
self.stats.energy -= amount
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]:
"""Remove expired resources from inventory. Returns list of removed resources."""
"""Remove expired resources from inventory."""
expired = []
for resource in self.inventory[:]:
if resource.is_expired(current_turn):
@ -478,15 +551,38 @@ class Agent:
def apply_passive_decay(self) -> None:
"""Apply passive stat decay for this turn."""
self.stats.apply_passive_decay(has_clothes=self.has_clothes())
self.religion.apply_decay()
def mark_dead(self, turn: int, reason: str) -> None:
"""Mark this agent as dead."""
self.death_turn = turn
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:
"""Convert to dictionary for API serialization."""
# Update profession before serializing
self._update_profession()
return {
@ -505,10 +601,13 @@ class Agent:
"last_action_result": self.last_action_result,
"death_turn": self.death_turn,
"death_reason": self.death_reason,
# New fields for agent diversity
# Personality and skills
"personality": self.personality.to_dict(),
"skills": self.skills.to_dict(),
"actions_performed": self.actions_performed.copy(),
"total_trades": self.total_trades_completed,
"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"
HIDE = "hide"
CLOTHES = "clothes"
# NEW: Oil industry resources
OIL = "oil" # Raw crude oil - must be refined
FUEL = "fuel" # Refined fuel - provides heat and energy
@dataclass
@ -32,7 +35,6 @@ class ResourceEffect:
def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
"""Get resource effects from the global config."""
# Import here to avoid circular imports
from backend.config import 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.HIDE: ResourceEffect(), # Used for crafting
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]]:
"""Get resource decay rates from the global config."""
# Import here to avoid circular imports
from backend.config import get_config
config = get_config()
@ -71,6 +80,9 @@ def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
ResourceType.WOOD: None, # Infinite
ResourceType.HIDE: None, # Infinite
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
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
_resource_effects_cache: Optional[dict[ResourceType, ResourceEffect]] = None
_resource_decay_cache: Optional[dict[ResourceType, Optional[int]]] = None
@ -140,6 +158,37 @@ RESOURCE_EFFECTS = _ResourceEffectsAccessor()
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
class Resource:
"""A resource instance in the simulation."""
@ -157,6 +206,16 @@ class Resource:
"""Get the effect of consuming this resource."""
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:
"""Check if the resource has decayed."""
if self.decay_rate is None:
@ -177,4 +236,5 @@ class Resource:
"quantity": self.quantity,
"created_turn": self.created_turn,
"decay_rate": self.decay_rate,
"base_value": self.base_value,
}

View File

@ -1,73 +1,118 @@
{
"agent_stats": {
"max_energy": 50,
"max_energy": 60,
"max_hunger": 100,
"max_thirst": 100,
"max_heat": 100,
"start_energy": 50,
"start_hunger": 70,
"start_thirst": 75,
"max_faith": 100,
"start_energy": 60,
"start_hunger": 90,
"start_thirst": 90,
"start_heat": 100,
"start_faith": 45,
"energy_decay": 1,
"hunger_decay": 2,
"thirst_decay": 3,
"heat_decay": 3,
"critical_threshold": 0.25,
"hunger_decay": 1,
"thirst_decay": 2,
"heat_decay": 2,
"faith_decay": 1,
"critical_threshold": 0.18,
"low_energy_threshold": 12
},
"resources": {
"meat_decay": 10,
"berries_decay": 6,
"clothes_decay": 20,
"meat_hunger": 35,
"meat_energy": 12,
"berries_hunger": 10,
"berries_thirst": 4,
"water_thirst": 50,
"fire_heat": 20
"meat_decay": 15,
"berries_decay": 10,
"clothes_decay": 30,
"oil_decay": 0,
"fuel_decay": 0,
"meat_hunger": 45,
"meat_energy": 15,
"berries_hunger": 15,
"berries_thirst": 6,
"water_thirst": 60,
"fire_heat": 30,
"fuel_heat": 45,
"oil_energy": 0,
"fuel_energy": 12
},
"actions": {
"sleep_energy": 55,
"rest_energy": 12,
"hunt_energy": -7,
"gather_energy": -3,
"chop_wood_energy": -6,
"rest_energy": 15,
"hunt_energy": -5,
"gather_energy": -2,
"chop_wood_energy": -4,
"get_water_energy": -2,
"weave_energy": -6,
"build_fire_energy": -4,
"weave_energy": -4,
"build_fire_energy": -3,
"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,
"hunt_meat_min": 2,
"hunt_meat_max": 5,
"drill_oil_success": 0.70,
"hunt_meat_min": 3,
"hunt_meat_max": 6,
"hunt_hide_min": 0,
"hunt_hide_max": 2,
"gather_min": 2,
"gather_max": 4,
"chop_wood_min": 1,
"chop_wood_max": 3
"gather_min": 3,
"gather_max": 6,
"chop_wood_min": 2,
"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": {
"width": 25,
"height": 25,
"initial_agents": 25,
"width": 50,
"height": 50,
"initial_agents": 100,
"day_steps": 10,
"night_steps": 1,
"inventory_slots": 12,
"starting_money": 80
"inventory_slots": 14,
"starting_money": 100,
"oil_fields_count": 5,
"temple_count": 5
},
"market": {
"turns_before_discount": 15,
"discount_rate": 0.12,
"base_price_multiplier": 1.3
"turns_before_discount": 10,
"discount_rate": 0.08,
"base_price_multiplier": 1.15
},
"economy": {
"energy_to_money_ratio": 1.5,
"wealth_desire": 0.35,
"buy_efficiency_threshold": 0.75,
"min_wealth_target": 50,
"max_price_markup": 2.5,
"min_price_discount": 0.4
"energy_to_money_ratio": 1.2,
"wealth_desire": 0.25,
"buy_efficiency_threshold": 0.85,
"min_wealth_target": 30,
"max_price_markup": 1.8,
"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
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Optional, Any
import requests
@ -25,6 +28,15 @@ class SimulationState:
is_running: bool
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
def from_api_response(cls, data: dict) -> "SimulationState":
"""Create from API response data."""
@ -42,12 +54,75 @@ class SimulationState:
mode=data.get("mode", "manual"),
is_running=data.get("is_running", False),
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]:
"""Get only living agents."""
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:
"""HTTP client for the Village Simulation backend."""
@ -82,7 +157,7 @@ class SimulationClient:
self.connected = True
self._retry_count = 0
return response.json()
except RequestException as e:
except RequestException:
self._retry_count += 1
if self._retry_count >= self._max_retries:
self.connected = False
@ -107,7 +182,7 @@ class SimulationClient:
if data:
self.last_state = SimulationState.from_api_response(data)
return self.last_state
return self.last_state # Return cached state if request failed
return self.last_state
def advance_turn(self) -> bool:
"""Advance the simulation by one step."""
@ -121,9 +196,9 @@ class SimulationClient:
def initialize(
self,
num_agents: int = 8,
world_width: int = 20,
world_height: int = 20,
num_agents: int = 100,
world_width: int = 30,
world_height: int = 30,
) -> bool:
"""Initialize or reset the simulation."""
result = self._request("POST", "/control/initialize", json={
@ -177,4 +252,3 @@ class SimulationClient:
"""Reset configuration to defaults."""
result = self._request("POST", "/config/reset")
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 pygame
@ -12,14 +15,13 @@ from frontend.renderer.stats_renderer import StatsRenderer
# Window configuration
WINDOW_WIDTH = 1200
WINDOW_HEIGHT = 800
WINDOW_TITLE = "Village Economy Simulation"
FPS = 30
WINDOW_TITLE = "Village Simulation - Economy, Religion & Diplomacy"
FPS = 60
# Layout configuration
TOP_PANEL_HEIGHT = 50
RIGHT_PANEL_WIDTH = 200
# Layout ratios (will scale with screen)
TOP_PANEL_HEIGHT_RATIO = 0.06
RIGHT_PANEL_WIDTH_RATIO = 0.22
BOTTOM_PANEL_HEIGHT_RATIO = 0.08
class VillageSimulationApp:
@ -30,31 +32,60 @@ class VillageSimulationApp:
pygame.init()
pygame.font.init()
# Create window
self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
# Get display info for fullscreen
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)
# Hide mouse cursor briefly on startup
pygame.mouse.set_visible(True)
# Clock for FPS control
self.clock = pygame.time.Clock()
# Fonts
self.font = pygame.font.Font(None, 24)
# Calculate layout dimensions
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
self.client = SimulationClient(server_url)
# Calculate map area
# Calculate map area (left side, between top and bottom panels)
self.map_rect = pygame.Rect(
0,
TOP_PANEL_HEIGHT,
WINDOW_WIDTH - RIGHT_PANEL_WIDTH,
WINDOW_HEIGHT - TOP_PANEL_HEIGHT,
self.top_panel_height,
self.screen_width - self.right_panel_width,
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.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font)
self.ui_renderer = UIRenderer(self.screen, self.font)
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font_small)
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.stats_renderer = StatsRenderer(self.screen)
@ -62,26 +93,25 @@ class VillageSimulationApp:
self.state: SimulationState | None = None
self.running = True
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)
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
self._setup_settings_callbacks()
def _setup_settings_callbacks(self) -> None:
"""Set up callbacks for the settings panel."""
# Override the apply and reset callbacks
original_apply = self.settings_renderer._apply_config
original_reset = self.settings_renderer._reset_config
def apply_config():
config = self.settings_renderer.get_config()
if self.client.update_config(config):
# Restart simulation with new config
if self.client.initialize():
num_agents = config.get("world", {}).get("initial_agents", 100)
if self.client.initialize(num_agents=num_agents):
self.state = self.client.get_state()
self.settings_renderer.status_message = "Config applied & simulation restarted!"
self.settings_renderer.status_color = (80, 180, 100)
@ -94,7 +124,6 @@ class VillageSimulationApp:
def reset_config():
if self.client.reset_config():
# Reload config from server
config = self.client.get_config()
if config:
self.settings_renderer.set_config(config)
@ -147,13 +176,12 @@ class VillageSimulationApp:
# Advance one turn
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
if self.client.advance_turn():
# Immediately fetch new state
self.state = self.client.get_state()
elif event.key == pygame.K_r:
# Reset simulation
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.stats_renderer.clear_history()
self._last_turn = -1
@ -177,6 +205,10 @@ class VillageSimulationApp:
self._load_config()
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:
"""Handle mouse motion for agent hover detection."""
if not self.state or self.settings_renderer.visible:
@ -190,7 +222,7 @@ class VillageSimulationApp:
if not self.map_rect.collidepoint(mouse_pos):
return
# Check each agent
# Check each agent (only check visible ones for performance)
for agent in self.state.agents:
if not agent.get("is_alive", False):
continue
@ -242,8 +274,8 @@ class VillageSimulationApp:
def draw(self) -> None:
"""Draw all elements."""
# Clear screen
self.screen.fill((30, 35, 45))
# Clear screen with dark background
self.screen.fill((15, 17, 23))
if self.state:
# Draw map
@ -252,7 +284,7 @@ class VillageSimulationApp:
# Draw agents
self.agent_renderer.draw(self.state)
# Draw UI
# Draw UI panels
self.ui_renderer.draw(self.state)
# Draw agent tooltip if hovering
@ -272,16 +304,22 @@ class VillageSimulationApp:
# Draw hints at bottom
if not self.settings_renderer.visible and not self.stats_renderer.visible:
hint_font = pygame.font.Font(None, 18)
hint = hint_font.render("S: Settings | G: Statistics & Graphs", True, (100, 100, 120))
self.screen.blit(hint, (5, self.screen.get_height() - 20))
hint_font = pygame.font.Font(None, 16)
hint = hint_font.render(
"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
pygame.display.flip()
def run(self) -> None:
"""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...")
# Try to connect initially
@ -292,12 +330,13 @@ class VillageSimulationApp:
self.state = self.client.get_state()
print("\nControls:")
print(" SPACE - Advance turn")
print(" R - Reset simulation")
print(" M - Toggle auto/manual mode")
print(" S - Open settings")
print(" G - Open statistics & graphs")
print(" ESC - Close panel / Quit")
print(" SPACE - Advance turn")
print(" R - Reset simulation (100 agents)")
print(" M - Toggle auto/manual mode")
print(" S - Open settings")
print(" G - Open statistics & graphs")
print(" F - Toggle fullscreen")
print(" ESC - Close panel / Quit")
print()
while self.running:
@ -311,7 +350,6 @@ class VillageSimulationApp:
def main():
"""Entry point for the frontend application."""
# Get server URL from command line if provided
server_url = "http://localhost:8000"
if len(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 .ui_renderer import UIRenderer
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 pygame
@ -9,42 +12,53 @@ if TYPE_CHECKING:
from frontend.renderer.map_renderer import MapRenderer
# Profession colors (villager is the default now)
PROFESSION_COLORS = {
"villager": (100, 140, 180), # Blue-gray for generic villager
"hunter": (180, 80, 80), # Red
"gatherer": (80, 160, 80), # Green
"woodcutter": (139, 90, 43), # Brown
"crafter": (160, 120, 200), # Purple
# Faction colors - matches backend FactionType
FACTION_COLORS = {
"northlands": (100, 160, 220), # Ice blue
"riverfolk": (70, 160, 180), # River teal
"forestkin": (90, 160, 80), # Forest green
"mountaineer": (150, 120, 90), # Mountain brown
"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 = (60, 60, 60) # Dark gray
CORPSE_COLOR = (50, 50, 55)
# Status bar colors
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 (simplified for performance)
ACTION_SYMBOLS = {
"hunt": "🏹",
"gather": "🍇",
"chop_wood": "🪓",
"get_water": "💧",
"weave": "🧵",
"build_fire": "🔥",
"trade": "💰",
"rest": "💤",
"sleep": "😴",
"consume": "🍖",
"dead": "💀",
"hunt": "",
"gather": "",
"chop_wood": "",
"get_water": "",
"weave": "",
"build_fire": "",
"trade": "$",
"rest": "",
"sleep": "",
"consume": "",
"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 = {
"hunt": "H",
"gather": "G",
@ -56,12 +70,20 @@ ACTION_LETTERS = {
"rest": "R",
"sleep": "Z",
"consume": "E",
"drill_oil": "O",
"refine": "U",
"pray": "P",
"preach": "!",
"negotiate": "N",
"declare_war": "!",
"make_peace": "+",
"burn_fuel": "B",
"dead": "X",
}
class AgentRenderer:
"""Renders agents on the map with movement and action indicators."""
"""Renders agents on the map with faction/religion indicators."""
def __init__(
self,
@ -72,89 +94,116 @@ class AgentRenderer:
self.screen = screen
self.map_renderer = map_renderer
self.font = font
self.small_font = pygame.font.Font(None, 16)
self.action_font = pygame.font.Font(None, 20)
self.small_font = pygame.font.Font(None, 14)
self.action_font = pygame.font.Font(None, 16)
self.tooltip_font = pygame.font.Font(None, 18)
# Animation state
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]:
"""Get the color for an agent based on state."""
# Corpses are dark gray
"""Get the main color for an agent (faction-based)."""
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
return CORPSE_COLOR
profession = agent.get("profession", "villager")
base_color = PROFESSION_COLORS.get(profession, (100, 140, 180))
base_color = self._get_faction_color(agent)
if not agent.get("can_act", True):
# Slightly dimmed for exhausted agents
return tuple(int(c * 0.7) for c in base_color)
# Dimmed for exhausted agents
return tuple(int(c * 0.6) for c in base_color)
return base_color
def _draw_status_bar(
def _draw_mini_bar(
self,
x: int,
y: int,
width: int,
height: int,
value: int,
max_value: int,
value: float,
max_value: float,
color: tuple[int, int, int],
) -> None:
"""Draw a single status bar."""
"""Draw a tiny status bar."""
if max_value <= 0:
return
# 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_width = int((value / max_value) * width) if max_value > 0 else 0
fill_width = int((value / max_value) * width)
if fill_width > 0:
pygame.draw.rect(self.screen, color, (x, y, fill_width, height))
# Color gradient based on value
ratio = value / max_value
if ratio < 0.25:
bar_color = (200, 60, 60) # Critical - red
elif ratio < 0.5:
bar_color = (200, 150, 60) # Low - orange
else:
bar_color = color
pygame.draw.rect(self.screen, bar_color, (x, y, fill_width, height))
# Border
pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1)
def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None:
"""Draw status bars below the agent."""
def _draw_status_bars(
self,
agent: dict,
center_x: int,
center_y: int,
size: int
) -> None:
"""Draw compact status bars below the agent."""
stats = agent.get("stats", {})
bar_width = size + 10
bar_height = 3
bar_spacing = 4
start_y = center_y + size // 2 + 4
bar_width = size + 6
bar_height = 2
bar_spacing = 3
start_y = center_y + size // 2 + 3
bars = [
("energy", stats.get("energy", 0), stats.get("max_energy", 100)),
("hunger", stats.get("hunger", 0), stats.get("max_hunger", 100)),
("thirst", stats.get("thirst", 0), stats.get("max_thirst", 50)),
("heat", stats.get("heat", 0), stats.get("max_heat", 100)),
(stats.get("energy", 0), stats.get("max_energy", 100), (220, 200, 80)),
(stats.get("hunger", 0), stats.get("max_hunger", 100), (200, 130, 80)),
(stats.get("thirst", 0), stats.get("max_thirst", 100), (80, 160, 200)),
]
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
self._draw_status_bar(
self._draw_mini_bar(
center_x - bar_width // 2,
bar_y,
bar_width,
bar_height,
value,
max_value,
BAR_COLORS[stat_name],
color,
)
def _draw_action_indicator(
def _draw_action_bubble(
self,
agent: dict,
center_x: int,
center_y: int,
agent_size: int,
) -> None:
"""Draw action indicator above the agent."""
"""Draw action indicator bubble above agent."""
current_action = agent.get("current_action", {})
action_type = current_action.get("action_type", "")
is_moving = current_action.get("is_moving", False)
message = current_action.get("message", "")
if not action_type:
return
@ -162,170 +211,191 @@ class AgentRenderer:
# Get action symbol
symbol = ACTION_LETTERS.get(action_type, "?")
# Draw action bubble above agent
bubble_y = center_y - agent_size // 2 - 20
# Position above agent
bubble_y = center_y - agent_size // 2 - 12
# Animate if moving
is_moving = current_action.get("is_moving", False)
if is_moving:
# Bouncing animation
offset = int(3 * math.sin(self.animation_tick * 0.3))
offset = int(2 * math.sin(self.animation_tick * 0.3))
bubble_y += offset
# Draw bubble background
bubble_width = 22
bubble_height = 18
# Draw small bubble
bubble_w, bubble_h = 14, 12
bubble_rect = pygame.Rect(
center_x - bubble_width // 2,
bubble_y - bubble_height // 2,
bubble_width,
bubble_height,
center_x - bubble_w // 2,
bubble_y - bubble_h // 2,
bubble_w,
bubble_h,
)
# Color based on action success/failure
if "Failed" in message:
bg_color = (120, 60, 60)
# Color based on action type
if action_type in ["pray", "preach"]:
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)
elif action_type in ["drill_oil", "refine", "burn_fuel"]:
bg_color = (60, 55, 40)
border_color = (140, 120, 80)
elif is_moving:
bg_color = (60, 80, 120)
bg_color = (50, 60, 80)
border_color = (100, 140, 200)
else:
bg_color = (50, 70, 50)
border_color = (80, 140, 80)
bg_color = (40, 55, 45)
border_color = (80, 130, 90)
pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=4)
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, 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=3)
# Draw action letter
text = self.action_font.render(symbol, True, (255, 255, 255))
# Draw symbol
text = self.action_font.render(symbol, True, (230, 230, 230))
text_rect = text.get_rect(center=(center_x, bubble_y))
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(
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(
def _draw_religion_indicator(
self,
agent: dict,
center_x: int,
center_y: int,
agent_size: int,
) -> None:
"""Draw the last action result as floating text."""
result = agent.get("last_action_result", "")
if not result:
return
"""Draw a small religion indicator (faith glow)."""
faith = agent.get("faith", 50)
religion_color = self._get_religion_color(agent)
# Truncate long messages
if len(result) > 25:
result = result[:22] + "..."
# Only show for agents with significant faith
if faith > 70:
# 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
text_y = center_y + agent_size // 2 + 22
# Small religion dot indicator
dot_x = center_x + agent_size // 2 - 2
dot_y = center_y - agent_size // 2 + 2
pygame.draw.circle(self.screen, religion_color, (dot_x, dot_y), 3)
pygame.draw.circle(self.screen, (30, 30, 35), (dot_x, dot_y), 3, 1)
text = self.small_font.render(result, True, (180, 180, 180))
text_rect = text.get_rect(center=(center_x, text_y))
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)
# Background for readability
bg_rect = text_rect.inflate(4, 2)
pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect)
self.screen.blit(text, text_rect)
if at_war:
# Red war indicator
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.15)
war_color = (int(200 * pulse), int(50 * pulse), int(50 * pulse))
pygame.draw.circle(
self.screen, war_color,
(center_x - 6, center_y - 6),
3,
)
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
cell_w, cell_h = self.map_renderer.get_cell_size()
agent_size = min(cell_w, cell_h) - 8
agent_size = max(10, min(agent_size, 30)) # Clamp size
agent_size = min(cell_w, cell_h) - 6
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:
is_corpse = agent.get("is_corpse", False)
is_alive = agent.get("is_alive", True)
if agent.get("is_corpse", False):
corpses.append(agent)
elif agent.get("is_alive", True):
living.append(agent)
# Get screen position from agent's current position
# Draw corpses first (behind living agents)
for agent in corpses:
pos = agent.get("position", {"x": 0, "y": 0})
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
self._draw_corpse(agent, screen_x, screen_y, agent_size)
# Draw living agents
for agent in living:
pos = agent.get("position", {"x": 0, "y": 0})
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
if is_corpse:
# Draw corpse with death indicator
self._draw_corpse(agent, screen_x, screen_y, agent_size)
continue
# Religion glow (full detail only)
if self.detail_level >= 2:
self._draw_religion_indicator(agent, screen_x, screen_y, agent_size)
if not is_alive:
continue
# Action bubble (basic+ detail)
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)
self._draw_action_indicator(agent, screen_x, screen_y, agent_size)
# Draw agent circle
# Main agent circle with faction color
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", {})
is_moving = current_action.get("is_moving", False)
if is_moving:
# Pulsing border when moving
pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2))
pulse = int(180 + 75 * math.sin(self.animation_tick * 0.2))
border_color = (pulse, pulse, 255)
elif agent.get("can_act"):
border_color = (255, 255, 255)
border_color = (200, 200, 210)
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)
if money > 0:
coin_x = screen_x + agent_size // 2 - 4
coin_y = screen_y - agent_size // 2 - 4
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 4)
pygame.draw.circle(self.screen, (200, 160, 0), (coin_x, coin_y), 4, 1)
if money > 50:
coin_x = screen_x + agent_size // 2 - 2
coin_y = screen_y - agent_size // 2 - 2
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 3)
# Draw "V" for villager
text = self.small_font.render("V", True, (255, 255, 255))
text_rect = text.get_rect(center=(screen_x, screen_y))
self.screen.blit(text, text_rect)
# War indicator
if self.detail_level >= 1:
self._draw_war_indicator(agent, screen_x, screen_y)
# Draw status bars
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)
# Status bars (basic+ detail)
if self.detail_level >= 1:
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
def _draw_corpse(
self,
@ -334,97 +404,117 @@ class AgentRenderer:
center_y: int,
agent_size: int,
) -> None:
"""Draw a corpse with death reason displayed."""
# Draw corpse circle (dark gray)
pygame.draw.circle(self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 2)
"""Draw a corpse marker."""
# Simple X marker
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
pygame.draw.circle(self.screen, (150, 50, 50), (center_x, center_y), agent_size // 2, 2)
# Draw skull symbol
text = self.action_font.render("X", True, (180, 80, 80))
text_rect = text.get_rect(center=(center_x, center_y))
self.screen.blit(text, text_rect)
# Draw death reason above corpse
death_reason = agent.get("death_reason", "unknown")
name = agent.get("name", "Unknown")
# Death indicator bubble
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)
# X symbol
half = agent_size // 4
pygame.draw.line(
self.screen, (120, 60, 60),
(center_x - half, center_y - half),
(center_x + half, center_y + half),
1,
)
pygame.draw.line(
self.screen, (120, 60, 60),
(center_x + half, center_y - half),
(center_x - half, center_y + half),
1,
)
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
"""Draw a tooltip for an agent when hovered."""
# Build tooltip text
lines = [
agent.get("name", "Unknown"),
f"Profession: {agent.get('profession', '?').capitalize()}",
f"Money: {agent.get('money', 0)} coins",
"",
]
"""Draw a detailed tooltip for hovered agent."""
lines = []
# Name and faction
name = agent.get("name", "Unknown")
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 = agent.get("current_action", {})
action_type = current_action.get("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(f"Action: {action_type.replace('_', ' ').title()}")
if current_action.get("is_moving"):
lines.append(" (moving)")
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', 50)}")
lines.append(f" Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}")
# Inventory summary
inventory = agent.get("inventory", [])
if inventory:
lines.append("")
lines.append("Inventory:")
for item in inventory[:5]:
lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}")
for item in inventory[:4]:
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
last_result = agent.get("last_action_result", "")
if last_result:
lines.append("")
lines.append(f"Last: {last_result[:35]}")
# Calculate tooltip size
# Calculate size
line_height = 16
max_width = max(self.small_font.size(line)[0] for line in lines) + 20
height = len(lines) * line_height + 10
max_width = max(self.tooltip_font.size(line)[0] for line in lines if line) + 24
height = len(lines) * line_height + 16
# Position tooltip near mouse but not off screen
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 5)
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 5)
# Position
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 10)
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)
pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect)
pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1)
pygame.draw.rect(self.screen, (25, 28, 35), tooltip_rect, border_radius=6)
# 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
for i, line in enumerate(lines):
text = self.small_font.render(line, True, (220, 220, 220))
self.screen.blit(text, (x + 10, y + 5 + i * line_height))
if not line:
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
from typing import TYPE_CHECKING
@ -7,29 +12,58 @@ if TYPE_CHECKING:
from frontend.client import SimulationState
# Color palette
# Color palette - Cyberpunk dark theme
class Colors:
# Background colors
DAY_BG = (180, 200, 160) # Soft green for day
NIGHT_BG = (40, 45, 60) # Dark blue for night
GRID_LINE = (120, 140, 110) # Subtle grid lines
GRID_LINE_NIGHT = (60, 65, 80)
DAY_BG = (28, 35, 42)
NIGHT_BG = (12, 14, 20)
# Terrain features (for visual variety)
GRASS_LIGHT = (160, 190, 140)
GRASS_DARK = (140, 170, 120)
WATER_SPOT = (100, 140, 180)
# Terrain
GRASS_LIGHT = (32, 45, 38)
GRASS_DARK = (26, 38, 32)
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:
"""Renders the map/terrain background."""
"""Renders the map/terrain background with special locations."""
def __init__(
self,
screen: pygame.Surface,
map_rect: pygame.Rect,
world_width: int = 20,
world_height: int = 20,
world_width: int = 30,
world_height: int = 30,
):
self.screen = screen
self.map_rect = map_rect
@ -38,24 +72,40 @@ class MapRenderer:
self._cell_width = map_rect.width / world_width
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()
# 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]]:
"""Generate simple terrain variation (0 = light, 1 = dark, 2 = water)."""
import random
"""Generate terrain variation using noise-like pattern."""
random.seed(42) # Consistent terrain
terrain = []
for y in range(self.world_height):
row = []
for x in range(self.world_width):
# Simple pattern: mostly grass with occasional water spots
if random.random() < 0.05:
row.append(2) # Water spot
elif (x + y) % 3 == 0:
# Create organic-looking patterns
noise = (
math.sin(x * 0.3) * math.cos(y * 0.3) +
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
elif noise < -0.5:
row.append(3) # Accent grass
else:
row.append(0) # Light grass
terrain.append(row)
return terrain
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_height = self.map_rect.height / world_height
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]:
"""Convert grid coordinates to screen coordinates (center of cell)."""
@ -77,70 +128,212 @@ class MapRenderer:
"""Get the size of a single cell."""
return int(self._cell_width), int(self._cell_height)
def draw(self, state: "SimulationState") -> None:
"""Draw the map background."""
is_night = state.time_of_day == "night"
def _render_terrain_surface(self, is_night: bool) -> pygame.Surface:
"""Render terrain to a cached surface."""
surface = pygame.Surface((self.map_rect.width, self.map_rect.height))
# Fill background
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
for y in range(self.world_height):
for x in range(self.world_width):
cell_rect = pygame.Rect(
self.map_rect.left + x * self._cell_width,
self.map_rect.top + y * self._cell_height,
self._cell_width + 1, # +1 to avoid gaps
x * self._cell_width,
y * self._cell_height,
self._cell_width + 1,
self._cell_height + 1,
)
terrain_type = self._terrain_cache[y][x]
if is_night:
# Darker colors at night
if terrain_type == 2:
color = (60, 80, 110)
color = (15, 25, 40)
elif terrain_type == 1:
color = (35, 40, 55)
color = (18, 25, 22)
elif terrain_type == 3:
color = (22, 30, 26)
else:
color = (45, 50, 65)
color = (20, 28, 24)
else:
if terrain_type == 2:
color = Colors.WATER_SPOT
elif terrain_type == 1:
color = Colors.GRASS_DARK
elif terrain_type == 3:
color = Colors.GRASS_ACCENT
else:
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
# Vertical lines
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(
self.screen,
surface,
grid_color,
(start_x, self.map_rect.top),
(start_x, self.map_rect.bottom),
(start_x, 0),
(start_x, self.map_rect.height),
1,
)
# Horizontal lines
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(
self.screen,
surface,
grid_color,
(self.map_rect.left, start_y),
(self.map_rect.right, start_y),
(0, start_y),
(self.map_rect.width, start_y),
1,
)
# Draw border
border_color = (80, 90, 70) if not is_night else (80, 85, 100)
return surface
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)
# 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
from dataclasses import dataclass
@ -7,76 +10,132 @@ from typing import Optional, Callable, Any
class Colors:
"""Color palette for settings UI."""
BG = (25, 28, 35)
PANEL_BG = (35, 40, 50)
PANEL_BORDER = (70, 80, 95)
TEXT_PRIMARY = (230, 230, 235)
TEXT_SECONDARY = (160, 165, 175)
TEXT_HIGHLIGHT = (100, 180, 255)
SLIDER_BG = (50, 55, 65)
SLIDER_FILL = (80, 140, 200)
BG = (15, 17, 23)
PANEL_BG = (22, 26, 35)
PANEL_HEADER = (28, 33, 45)
PANEL_BORDER = (50, 60, 80)
TEXT_PRIMARY = (225, 228, 235)
TEXT_SECONDARY = (140, 150, 165)
TEXT_HIGHLIGHT = (100, 200, 255)
SLIDER_BG = (40, 45, 55)
SLIDER_FILL = (70, 130, 200)
SLIDER_HANDLE = (220, 220, 230)
BUTTON_BG = (60, 100, 160)
BUTTON_HOVER = (80, 120, 180)
BUTTON_BG = (50, 90, 150)
BUTTON_HOVER = (70, 110, 170)
BUTTON_TEXT = (255, 255, 255)
SUCCESS = (80, 180, 100)
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
class SliderConfig:
"""Configuration for a slider widget."""
name: str
key: str # Dot-separated path like "agent_stats.max_energy"
key: str
min_val: float
max_val: float
step: float = 1.0
is_int: bool = True
description: str = ""
section: str = "General"
# Define all configurable parameters with sliders
# Organized slider configs by section
SLIDER_CONFIGS = [
# Agent Stats Section
SliderConfig("Max Energy", "agent_stats.max_energy", 50, 200, 10, True, "Maximum energy capacity"),
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("Max Heat", "agent_stats.max_heat", 50, 200, 10, True, "Maximum heat capacity"),
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 10, 1, True, "Energy lost per turn"),
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 10, 1, True, "Hunger lost per turn"),
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 10, 1, True, "Thirst lost per turn"),
SliderConfig("Heat Decay", "agent_stats.heat_decay", 1, 10, 1, True, "Heat lost per turn"),
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.5, 0.05, False, "Threshold for survival mode"),
# ═══════════════════════════════════════════════════════════════
# WORLD SETTINGS
# ═══════════════════════════════════════════════════════════════
SliderConfig("World Width", "world.width", 15, 60, 5, True, "Grid width", "World"),
SliderConfig("World Height", "world.height", 15, 60, 5, True, "Grid height", "World"),
SliderConfig("Initial Agents", "world.initial_agents", 10, 200, 10, True, "Starting population", "World"),
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day", "World"),
SliderConfig("Starting Money", "world.starting_money", 50, 300, 25, True, "Initial coins", "World"),
SliderConfig("Inventory Slots", "world.inventory_slots", 6, 20, 2, True, "Max items", "World"),
# World Section
SliderConfig("World Width", "world.width", 10, 50, 5, True, "World grid width"),
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("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day"),
SliderConfig("Inventory Slots", "world.inventory_slots", 5, 20, 1, True, "Agent inventory size"),
SliderConfig("Starting Money", "world.starting_money", 50, 500, 50, True, "Initial coins per agent"),
# ═══════════════════════════════════════════════════════════════
# AGENT STATS
# ═══════════════════════════════════════════════════════════════
SliderConfig("Max Energy", "agent_stats.max_energy", 30, 150, 10, True, "Energy capacity", "Stats"),
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Hunger capacity", "Stats"),
SliderConfig("Max Thirst", "agent_stats.max_thirst", 50, 200, 10, True, "Thirst capacity", "Stats"),
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"),
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("Sleep Restore", "actions.sleep_energy", 30, 100, 10, True, "Energy restored by sleep"),
SliderConfig("Rest Restore", "actions.rest_energy", 5, 30, 5, True, "Energy restored by rest"),
# ═══════════════════════════════════════════════════════════════
# ACTIONS
# ═══════════════════════════════════════════════════════════════
SliderConfig("Hunt Energy", "actions.hunt_energy", -15, -3, 1, True, "Energy cost", "Actions"),
SliderConfig("Gather Energy", "actions.gather_energy", -8, -1, 1, True, "Energy cost", "Actions"),
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"),
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("Water Thirst +", "resources.water_thirst", 20, 60, 5, True, "Thirst restored by water"),
# ═══════════════════════════════════════════════════════════════
# RELIGION
# ═══════════════════════════════════════════════════════════════
SliderConfig("Max Faith", "agent_stats.max_faith", 50, 150, 10, True, "Faith capacity", "Religion"),
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"),
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.30, 0.05, False, "Price reduction per period"),
# ═══════════════════════════════════════════════════════════════
# DIPLOMACY
# ═══════════════════════════════════════════════════════════════
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:
"""A slider widget for adjusting numeric values."""
@ -97,17 +156,17 @@ class Slider:
self.hovered = False
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))
if self.config.is_int:
self.value = int(round(self.value))
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)
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 self._slider_area().collidepoint(event.pos):
self.dragging = True
@ -124,27 +183,23 @@ class Slider:
return False
def _slider_area(self) -> pygame.Rect:
"""Get the actual slider track area."""
"""Get slider track area."""
return pygame.Rect(
self.rect.x + 120, # Leave space for label
self.rect.y + 15,
self.rect.width - 180, # Leave space for value display
20,
self.rect.x + 130,
self.rect.y + 12,
self.rect.width - 200,
16,
)
def _update_from_mouse(self, mouse_x: int) -> bool:
"""Update value based on mouse position."""
"""Update value from mouse."""
slider_area = self._slider_area()
# Calculate position as 0-1
rel_x = mouse_x - slider_area.x
ratio = max(0, min(1, rel_x / slider_area.width))
# Calculate value
range_val = self.config.max_val - self.config.min_val
new_value = self.config.min_val + ratio * range_val
# Apply step
if self.config.step > 0:
new_value = round(new_value / self.config.step) * self.config.step
@ -152,45 +207,44 @@ class Slider:
self.set_value(new_value)
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."""
# Background
# Hover highlight
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 = 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_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
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
fill_width = int(ratio * slider_area.width)
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_x = slider_area.x + fill_width
handle_rect = pygame.Rect(handle_x - 4, slider_area.y - 2, 8, slider_area.height + 4)
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=2)
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=3)
# Value display
value_str = str(self.get_value())
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
value_x = self.rect.right - 50
screen.blit(value_text, (value_x, self.rect.y + 5))
screen.blit(value_text, (self.rect.right - 55, self.rect.y + 6))
# Description on hover
if self.hovered and self.config.description:
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:
"""A simple button widget."""
"""Button widget."""
def __init__(
self,
@ -208,7 +262,7 @@ class Button:
self.hovered = False
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle input events. Returns True if clicked."""
"""Handle events."""
if event.type == pygame.MOUSEMOTION:
self.hovered = self.rect.collidepoint(event.pos)
@ -221,10 +275,10 @@ class Button:
return False
def draw(self, screen: pygame.Surface) -> None:
"""Draw the button."""
"""Draw button."""
color = Colors.BUTTON_HOVER if self.hovered else self.color
pygame.draw.rect(screen, color, self.rect, border_radius=5)
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, 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=6)
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
text_rect = text.get_rect(center=self.rect.center)
@ -232,21 +286,23 @@ class Button:
class SettingsRenderer:
"""Renders the settings UI panel with sliders."""
"""Settings panel with organized sections and sliders."""
def __init__(self, screen: pygame.Surface):
self.screen = screen
self.font = pygame.font.Font(None, 24)
self.small_font = pygame.font.Font(None, 18)
self.title_font = pygame.font.Font(None, 32)
self.font = pygame.font.Font(None, 22)
self.small_font = pygame.font.Font(None, 16)
self.title_font = pygame.font.Font(None, 28)
self.section_font = pygame.font.Font(None, 20)
self.visible = False
self.scroll_offset = 0
self.max_scroll = 0
self.current_section = 0
# Create sliders
self.sliders: list[Slider] = []
self.buttons: list[Button] = []
self.section_tabs: list[pygame.Rect] = []
self.config_data: dict = {}
self._create_widgets()
@ -254,32 +310,44 @@ class SettingsRenderer:
self.status_color = Colors.TEXT_SECONDARY
def _create_widgets(self) -> None:
"""Create slider widgets."""
panel_width = 400
slider_height = 45
start_y = 80
"""Create widgets."""
screen_w, screen_h = self.screen.get_size()
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):
rect = pygame.Rect(
panel_x + 10,
start_y + i * slider_height,
panel_width - 20,
slider_height,
)
self.panel_rect = pygame.Rect(panel_x, panel_y, panel_width, panel_height)
# Tab bar for sections
tab_height = 30
self.tab_rect = pygame.Rect(panel_x, panel_y, panel_width, tab_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)
self.sliders.append(slider)
self.sliders_by_section[config.section].append(slider)
# Calculate max scroll
total_height = len(SLIDER_CONFIGS) * slider_height + 150
visible_height = self.screen.get_height() - 150
self.max_scroll = max(0, total_height - visible_height)
# Calculate positions for current section
self._layout_current_section()
# Create buttons at the bottom
button_y = self.screen.get_height() - 60
button_width = 100
# Buttons at bottom
button_y = panel_y + panel_height - 50
button_width = 120
button_height = 35
button_spacing = 15
buttons_data = [
("Apply & Restart", self._apply_config, Colors.SUCCESS),
@ -287,26 +355,43 @@ class SettingsRenderer:
("Close", self.toggle, Colors.PANEL_BORDER),
]
total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10
start_x = (self.screen.get_width() - total_button_width) // 2
total_w = len(buttons_data) * button_width + (len(buttons_data) - 1) * button_spacing
start_x = panel_x + (panel_width - total_w) // 2
for i, (text, callback, color) in enumerate(buttons_data):
rect = pygame.Rect(
start_x + i * (button_width + 10),
start_x + i * (button_width + button_spacing),
button_y,
button_width,
button_height,
)
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:
"""Toggle settings visibility."""
"""Toggle visibility."""
self.visible = not self.visible
if self.visible:
self.scroll_offset = 0
self._layout_current_section()
def set_config(self, config_data: dict) -> None:
"""Set slider values from config data."""
"""Set slider values from config."""
self.config_data = config_data
for slider in self.sliders:
@ -315,16 +400,14 @@ class SettingsRenderer:
slider.set_value(value)
def get_config(self) -> dict:
"""Get current config from slider values."""
"""Get config from sliders."""
result = {}
for slider in self.sliders:
self._set_nested_value(result, slider.config.key, slider.get_value())
return result
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(".")
current = data
for part in parts:
@ -335,7 +418,7 @@ class SettingsRenderer:
return current
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(".")
current = data
for part in parts[:-1]:
@ -345,104 +428,138 @@ class SettingsRenderer:
current[parts[-1]] = value
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_color = Colors.SUCCESS
def _reset_config(self) -> None:
"""Reset configuration callback (to be set externally)."""
"""Reset config callback."""
self.status_message = "Config reset to defaults"
self.status_color = Colors.WARNING
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle input events. Returns True if event was consumed."""
"""Handle events."""
if not self.visible:
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:
self.scroll_offset -= event.y * 30
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
self._layout_current_section()
return True
# Handle sliders
for slider in self.sliders:
# Adjust slider position for scroll
original_y = slider.rect.y
slider.rect.y -= self.scroll_offset
# Sliders for current section
section = SECTION_ORDER[self.current_section]
for slider in self.sliders_by_section[section]:
adjusted_rect = slider.rect.copy()
if slider.handle_event(event):
slider.rect.y = original_y
return True
slider.rect.y = original_y
# Handle buttons
# Buttons
for button in self.buttons:
if button.handle_event(event):
return True
# Consume all clicks when settings are visible
# Consume clicks
if event.type == pygame.MOUSEBUTTONDOWN:
return True
return False
def draw(self) -> None:
"""Draw the settings panel."""
"""Draw settings panel."""
if not self.visible:
return
# Dim background
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))
# Panel background
panel_width = 420
panel_height = self.screen.get_height() - 40
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)
# Panel
pygame.draw.rect(self.screen, Colors.PANEL_BG, self.panel_rect, border_radius=10)
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, self.panel_rect, 2, border_radius=10)
# Title
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)
# Create clipping region for scrollable area
clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130)
# Section tabs
self._draw_section_tabs()
# Draw sliders with scroll offset
for slider in self.sliders:
# Adjust position for scroll
adjusted_rect = slider.rect.copy()
adjusted_rect.y -= self.scroll_offset
# Clipping for sliders
clip_rect = pygame.Rect(
self.panel_rect.x + 10,
self.panel_rect.y + 45,
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 sliders for current section
section = SECTION_ORDER[self.current_section]
section_color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
# Draw scroll indicator
for slider in self.sliders_by_section[section]:
if clip_rect.colliderect(slider.rect):
slider.draw(self.screen, section_color)
# Scroll indicator
if self.max_scroll > 0:
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))
scroll_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - scroll_height))
scroll_rect = pygame.Rect(panel_rect.right - 8, scroll_y, 4, scroll_height)
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, scroll_rect, border_radius=2)
bar_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
bar_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - bar_height))
bar_rect = pygame.Rect(self.panel_rect.right - 12, bar_y, 5, bar_height)
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, bar_rect, border_radius=2)
# Draw buttons
# Buttons
for button in self.buttons:
button.draw(self.screen)
# Status message
if self.status_message:
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)
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
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frontend.client import SimulationState
class Colors:
# UI colors
PANEL_BG = (35, 40, 50)
PANEL_BORDER = (70, 80, 95)
TEXT_PRIMARY = (230, 230, 235)
TEXT_SECONDARY = (160, 165, 175)
TEXT_HIGHLIGHT = (100, 180, 255)
# Base UI colors - dark cyberpunk theme
BG = (15, 17, 23)
PANEL_BG = (22, 26, 35)
PANEL_HEADER = (28, 33, 45)
PANEL_BORDER = (45, 55, 70)
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_DANGER = (255, 100, 100)
TEXT_SUCCESS = (100, 220, 140)
# Day/Night indicator
# Day/Night
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:
"""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.font = font
self.small_font = pygame.font.Font(None, 20)
self.title_font = pygame.font.Font(None, 28)
self.top_panel_height = top_panel_height
self.right_panel_width = right_panel_width
self.bottom_panel_height = bottom_panel_height
# Panel dimensions
self.top_panel_height = 50
self.right_panel_width = 200
# Fonts
self.small_font = pygame.font.Font(None, 16)
self.medium_font = pygame.font.Font(None, 20)
self.title_font = pygame.font.Font(None, 24)
self.large_font = pygame.font.Font(None, 28)
def _draw_panel(self, rect: pygame.Rect, title: Optional[str] = None) -> None:
"""Draw a panel background."""
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1)
# 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_bg(
self,
rect: pygame.Rect,
title: str = None,
accent_color: tuple = None,
) -> int:
"""Draw a panel background. Returns Y position after header."""
# Main background
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect, border_radius=4)
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1, border_radius=4)
y = rect.y + 6
if title:
title_text = self.small_font.render(title, True, Colors.TEXT_SECONDARY)
self.screen.blit(title_text, (rect.x + 8, rect.y + 4))
# Header area
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:
"""Draw the top information bar."""
self.animation_tick += 1
rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height)
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
pygame.draw.line(
self.screen,
Colors.PANEL_BORDER,
(0, self.top_panel_height),
(self.screen.get_width(), self.top_panel_height),
self.screen, Colors.PANEL_BORDER,
(0, self.top_panel_height - 1),
(self.screen.get_width(), self.top_panel_height - 1),
)
# Day/Night and Turn info
# Day/Night indicator with animated glow
is_night = state.time_of_day == "night"
time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR
time_text = "NIGHT" if is_night else "DAY"
# Draw time indicator circle
pygame.draw.circle(self.screen, time_color, (25, 25), 12)
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (25, 25), 12, 1)
# Glow effect
glow_alpha = int(100 + 50 * math.sin(self.animation_tick * 0.05))
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}"
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_text = f"Mode: {state.mode.upper()}"
text = self.small_font.render(mode_text, True, mode_color)
self.screen.blit(text, (self.screen.get_width() - 120, 8))
text = self.medium_font.render(mode_text, True, mode_color)
self.screen.blit(text, (right_x, 10))
# Running indicator
# Running status
if state.is_running:
status_text = "RUNNING"
status_color = (100, 200, 100)
status_text = "RUNNING"
status_color = Colors.TEXT_SUCCESS
else:
status_text = "STOPPED"
status_color = Colors.TEXT_DANGER
status_text = "STOPPED"
status_color = Colors.TEXT_SECONDARY
text = self.small_font.render(status_text, True, status_color)
self.screen.blit(text, (self.screen.get_width() - 120, 28))
text = self.medium_font.render(status_text, True, status_color)
self.screen.blit(text, (right_x, 28))
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_height = self.screen.get_height() - self.top_panel_height - self.bottom_panel_height
# Main panel background
rect = pygame.Rect(
panel_x,
self.top_panel_height,
self.right_panel_width,
self.screen.get_height() - self.top_panel_height,
panel_x, self.top_panel_height,
self.right_panel_width, panel_height,
)
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
pygame.draw.line(
self.screen,
Colors.PANEL_BORDER,
self.screen, Colors.PANEL_BORDER,
(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
# Statistics section
y = self._draw_statistics_section(state, panel_x + 10, y)
# ═══════════════════════════════════════════════════════════════
# STATISTICS SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_stats_section(state, content_x, y, content_width)
y += 15
# Market section
y = self._draw_market_section(state, panel_x + 10, y + 20)
# ═══════════════════════════════════════════════════════════════
# FACTIONS SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_factions_section(state, content_x, y, content_width)
y += 15
# Controls help section
self._draw_controls_help(panel_x + 10, self.screen.get_height() - 100)
# ═══════════════════════════════════════════════════════════════
# RELIGION SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_religion_section(state, content_x, y, content_width)
y += 15
def _draw_statistics_section(self, state: "SimulationState", x: int, y: int) -> int:
"""Draw the statistics section."""
# Title
title = self.title_font.render("Statistics", True, Colors.TEXT_PRIMARY)
self.screen.blit(title, (x, y))
y += 30
# ═══════════════════════════════════════════════════════════════
# 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_stats_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw statistics section."""
# Section header
text = self.title_font.render("📊 Statistics", True, Colors.TEXT_HIGHLIGHT)
self.screen.blit(text, (x, y))
y += 24
stats = state.statistics
# Population bar
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
pop_color = Colors.TEXT_PRIMARY if living > 2 else Colors.TEXT_DANGER
text = self.small_font.render(f"Population: {living}", True, pop_color)
self.screen.blit(text, (x, y))
y += 18
pygame.draw.rect(
self.screen, Colors.SCROLLBAR_BG,
(x, y, width, 14), border_radius=2,
)
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)
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)
text = self.small_font.render(f"Total Coins: {total_money}", True, Colors.TEXT_SECONDARY)
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_DANGER)
self.screen.blit(text, (x, y))
y += 18
# Professions
professions = stats.get("professions", {})
if professions:
y += 5
text = self.small_font.render("Professions:", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 16
text = self.small_font.render(f"💰 {total_money}", True, (255, 215, 0))
self.screen.blit(text, (x + width // 2, y))
y += 16
for prof, count in professions.items():
text = self.small_font.render(f" {prof}: {count}", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 14
# Average faith
avg_faith = state.get_avg_faith()
text = self.small_font.render(f"Avg Faith: {avg_faith:.0f}%", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 16
return y
def _draw_market_section(self, state: "SimulationState", x: int, y: int) -> int:
"""Draw the market section."""
# Title
title = self.title_font.render("Market", True, Colors.TEXT_PRIMARY)
self.screen.blit(title, (x, y))
y += 30
def _draw_factions_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw factions section with distribution bars."""
# Section header
text = self.title_font.render("⚔ Factions", True, (180, 160, 120))
self.screen.blit(text, (x, y))
y += 22
faction_stats = state.get_faction_stats()
total = sum(faction_stats.values()) or 1
# Sort by count
sorted_factions = sorted(
faction_stats.items(),
key=lambda x: x[1],
reverse=True
)
for faction, count in sorted_factions[:5]: # Top 5
color = Colors.FACTIONS.get(faction, Colors.FACTIONS["neutral"])
ratio = count / total
# Faction bar
bar_width = int(width * 0.6 * ratio * (total / max(1, sorted_factions[0][1])))
bar_width = max(4, min(bar_width, int(width * 0.6)))
pygame.draw.rect(
self.screen, (*color, 180),
(x, y, bar_width, 10), border_radius=2,
)
# Faction name and count
label = f"{faction[:8]}: {count}"
text = self.small_font.render(label, True, color)
self.screen.blit(text, (x + bar_width + 8, y - 1))
y += 14
return y
def _draw_religion_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw religion section with distribution."""
# Section header
text = self.title_font.render("✦ Religions", True, (200, 180, 220))
self.screen.blit(text, (x, y))
y += 22
religion_stats = state.get_religion_stats()
total = sum(religion_stats.values()) or 1
# Sort by count
sorted_religions = sorted(
religion_stats.items(),
key=lambda x: x[1],
reverse=True
)
for religion, count in sorted_religions[:5]: # Top 5
color = Colors.RELIGIONS.get(religion, Colors.RELIGIONS["atheist"])
ratio = count / total
# Religion color dot
pygame.draw.circle(self.screen, color, (x + 5, y + 5), 4)
# Religion name, count, and percentage
pct = ratio * 100
label = f"{religion[:8]}: {count} ({pct:.0f}%)"
text = self.small_font.render(label, True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x + 14, y))
y += 14
return y
def _draw_diplomacy_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw diplomacy section with wars and treaties."""
# Section header
text = self.title_font.render("🏛 Diplomacy", True, (120, 180, 200))
self.screen.blit(text, (x, y))
y += 22
# Active wars
active_wars = state.active_wars
if active_wars:
text = self.small_font.render("Active Wars:", True, Colors.TEXT_DANGER)
self.screen.blit(text, (x, y))
y += 14
for war in active_wars[:3]: # Show up to 3 wars
f1 = war.get("faction1", "?")[:6]
f2 = war.get("faction2", "?")[:6]
c1 = Colors.FACTIONS.get(war.get("faction1", "neutral"), (150, 150, 150))
c2 = Colors.FACTIONS.get(war.get("faction2", "neutral"), (150, 150, 150))
# War indicator
pygame.draw.circle(self.screen, c1, (x + 5, y + 5), 4)
text = self.small_font.render("", True, (200, 80, 80))
self.screen.blit(text, (x + 12, y - 1))
pygame.draw.circle(self.screen, c2, (x + 35, y + 5), 4)
war_text = f"{f1} vs {f2}"
text = self.small_font.render(war_text, True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x + 45, y))
y += 14
else:
text = self.small_font.render("☮ No active wars", True, Colors.TEXT_SUCCESS)
self.screen.blit(text, (x, y))
y += 14
# Peace treaties
peace_treaties = state.peace_treaties
if peace_treaties:
text = self.small_font.render(
f"Peace Treaties: {len(peace_treaties)}",
True, Colors.TEXT_SUCCESS
)
self.screen.blit(text, (x, y))
y += 14
# Recent diplomatic events
recent_events = state.diplomatic_events[:2]
if recent_events:
y += 4
for event in recent_events:
event_type = event.get("type", "unknown")
if event_type == "war_declared":
color = Colors.TEXT_DANGER
icon = ""
elif event_type == "peace_made":
color = Colors.TEXT_SUCCESS
icon = ""
else:
color = Colors.TEXT_SECONDARY
icon = ""
desc = event.get("description", event_type)[:25]
text = self.small_font.render(f"{icon} {desc}", True, color)
self.screen.blit(text, (x, y))
y += 12
return y
def _draw_market_section(
self, state: "SimulationState", x: int, y: int, width: int
) -> int:
"""Draw market section with prices."""
# Section header
text = self.title_font.render("💹 Market", True, (100, 200, 150))
self.screen.blit(text, (x, y))
y += 22
# Order count
order_count = len(state.market_orders)
text = self.small_font.render(f"Active Orders: {order_count}", True, Colors.TEXT_SECONDARY)
text = self.small_font.render(
f"Active Orders: {order_count}",
True, Colors.TEXT_SECONDARY
)
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
for resource, data in prices.items():
if data.get("total_available", 0) > 0:
shown = 0
for resource, data in sorted(prices.items()):
if shown >= 6: # Limit display
break
total_available = data.get("total_available", 0)
if total_available > 0:
price = data.get("lowest_price", "?")
qty = data.get("total_available", 0)
text = self.small_font.render(
f"{resource}: {qty}x @ {price}c",
True,
Colors.TEXT_SECONDARY,
)
# Resource color coding
if "oil" in resource.lower() or "fuel" in resource.lower():
res_color = (180, 160, 100)
elif "meat" in resource.lower():
res_color = (200, 120, 100)
elif "water" in resource.lower():
res_color = (100, 160, 200)
else:
res_color = Colors.TEXT_SECONDARY
res_text = f"{resource[:6]}: {total_available}x @ {price}c"
text = self.small_font.render(res_text, True, res_color)
self.screen.blit(text, (x, y))
y += 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
def _draw_controls_help(self, x: int, y: int) -> None:
"""Draw controls help at bottom of panel."""
def draw_bottom_bar(self, state: "SimulationState") -> None:
"""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(
self.screen,
Colors.PANEL_BORDER,
(x - 5, y - 10),
(self.screen.get_width() - 5, y - 10),
self.screen, Colors.PANEL_BORDER,
(0, bar_y), (self.screen.get_width(), bar_y),
)
title = self.small_font.render("Controls", True, Colors.TEXT_PRIMARY)
self.screen.blit(title, (x, y))
y += 20
# Recent events (religious + diplomatic)
x = 15
y = bar_y + 8
controls = [
"SPACE - Next Turn",
"R - Reset Simulation",
"M - Toggle Mode",
"S - Settings",
"ESC - Quit",
]
text = self.medium_font.render("Recent Events:", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
x += 120
for control in controls:
text = self.small_font.render(control, True, Colors.TEXT_SECONDARY)
# Show recent religious events
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))
y += 16
def draw_connection_status(self, connected: bool) -> None:
"""Draw connection status overlay when disconnected."""
@ -220,20 +594,52 @@ class UIRenderer:
# Semi-transparent overlay
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))
# Connection message
text = self.title_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
text_rect = text.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2))
# Connection box
box_w, box_h = 400, 150
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)
hint = self.small_font.render("Make sure the backend is running on localhost:8000", True, Colors.TEXT_SECONDARY)
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2 + 30))
hint = self.medium_font.render(
"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)
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:
"""Draw all UI elements."""
self.draw_top_bar(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)