Compare commits

..

1 Commits

Author SHA1 Message Date
Снесарев Максим
67dc007283 [new] web-based frontend 2026-01-19 19:44:38 +03:00
32 changed files with 5302 additions and 5545 deletions

View File

@ -28,12 +28,10 @@ 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):
@ -46,28 +44,6 @@ 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
@ -82,9 +58,6 @@ 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 ==============
@ -148,6 +121,12 @@ class StatisticsSchema(BaseModel):
total_agents_died: int
total_money_in_circulation: int
professions: dict[str, int]
# Wealth inequality metrics
avg_money: float = 0.0
median_money: int = 0
richest_agent: int = 0
poorest_agent: int = 0
gini_coefficient: float = 0.0
class ActionLogSchema(BaseModel):
@ -164,6 +143,18 @@ class TurnLogSchema(BaseModel):
agent_actions: list[ActionLogSchema]
deaths: list[str]
trades: list[dict]
resources_produced: dict[str, int] = {}
resources_consumed: dict[str, int] = {}
resources_spoiled: dict[str, int] = {}
class ResourceStatsSchema(BaseModel):
"""Schema for resource statistics."""
produced: dict[str, int] = {}
consumed: dict[str, int] = {}
spoiled: dict[str, int] = {}
in_inventory: dict[str, int] = {}
in_market: dict[str, int] = {}
class WorldStateResponse(BaseModel):
@ -179,6 +170,7 @@ class WorldStateResponse(BaseModel):
mode: str
is_running: bool
recent_logs: list[TurnLogSchema]
resource_stats: ResourceStatsSchema = ResourceStatsSchema()
# ============== Control Schemas ==============

View File

@ -12,49 +12,41 @@ class AgentStatsConfig:
# Maximum values
max_energy: int = 50
max_hunger: int = 100
max_thirst: int = 100
max_thirst: int = 100 # Increased from 50 to give more buffer
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
start_thirst: int = 80 # Increased from 40 to start with more buffer
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
thirst_decay: int = 2 # Reduced from 3 to match hunger decay rate
heat_decay: int = 2
faith_decay: int = 1 # NEW: Faith decays slowly without religious activity
# Thresholds
critical_threshold: float = 0.25
low_energy_threshold: int = 15
critical_threshold: float = 0.25 # 25% triggers survival mode
low_energy_threshold: int = 15 # Minimum energy to work
@dataclass
class ResourceConfig:
"""Configuration for resource properties."""
# Decay rates (turns until spoilage, 0 = infinite)
meat_decay: int = 8
meat_decay: int = 8 # Increased from 5 to give more time to use
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
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
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
@dataclass
@ -71,23 +63,9 @@ 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
@ -99,15 +77,6 @@ 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:
@ -122,58 +91,41 @@ 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
base_price_multiplier: float = 1.2
discount_rate: float = 0.15 # 15% discount after waiting
base_price_multiplier: float = 1.2 # Markup over production cost
@dataclass
class EconomyConfig:
"""Configuration for economic behavior and agent trading."""
energy_to_money_ratio: float = 1.5
"""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
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
# 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
# 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
@dataclass
@ -185,11 +137,9 @@ 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
auto_step_interval: float = 1.0 # Seconds between auto steps
def to_dict(self) -> dict:
"""Convert to dictionary."""
@ -200,8 +150,6 @@ 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,
}
@ -215,8 +163,6 @@ 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),
)
@ -233,7 +179,7 @@ class SimulationConfig:
data = json.load(f)
return cls.from_dict(data)
except FileNotFoundError:
return cls()
return cls() # Return defaults if file not found
# Global configuration instance
@ -241,7 +187,10 @@ _config: Optional[SimulationConfig] = None
def get_config() -> SimulationConfig:
"""Get the global configuration instance."""
"""Get the global configuration instance.
Loads from config.json if not already loaded.
"""
global _config
if _config is None:
_config = load_config()
@ -253,6 +202,8 @@ 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
@ -263,7 +214,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 SimulationConfig() # Return defaults if file not found
def set_config(config: SimulationConfig) -> None:
@ -301,3 +252,4 @@ def _reset_all_caches() -> None:
reset_resource_cache()
except ImportError:
pass

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,4 @@
"""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)
"""
"""Game Engine for the Village Simulation."""
import random
import threading
@ -15,12 +9,8 @@ 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, get_fire_heat, get_fuel_heat
from backend.domain.resources import Resource, ResourceType
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
@ -30,8 +20,8 @@ from backend.config import get_config
class SimulationMode(Enum):
"""Simulation run mode."""
MANUAL = "manual"
AUTO = "auto"
MANUAL = "manual" # Wait for explicit next_step call
AUTO = "auto" # Run automatically with timer
@dataclass
@ -41,8 +31,10 @@ 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
# Resource tracking for this turn
resources_produced: dict = field(default_factory=dict)
resources_consumed: dict = field(default_factory=dict)
resources_spoiled: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
@ -50,8 +42,9 @@ class TurnLog:
"agent_actions": self.agent_actions,
"deaths": self.deaths,
"trades": self.trades,
"religious_events": self.religious_events,
"diplomatic_events": self.diplomatic_events,
"resources_produced": self.resources_produced,
"resources_consumed": self.resources_consumed,
"resources_spoiled": self.resources_spoiled,
}
@ -74,20 +67,30 @@ 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()
self.turn_logs: list[TurnLog] = []
self.logger = get_simulation_logger()
# Resource statistics tracking (cumulative)
self.resource_stats = {
"produced": {}, # Total resources produced
"consumed": {}, # Total resources consumed
"spoiled": {}, # Total resources spoiled
"traded": {}, # Total resources traded (bought/sold)
"in_market": {}, # Currently in market
"in_inventory": {}, # Currently in all inventories
}
self._initialized = True
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:
@ -95,6 +98,17 @@ class GameEngine:
self.market = OrderBook()
self.turn_logs = []
# Reset resource statistics
self.resource_stats = {
"produced": {},
"consumed": {},
"spoiled": {},
"traded": {},
"in_market": {},
"in_inventory": {},
}
# Reset and start new logging session
self.logger = reset_simulation_logger()
sim_config = get_config()
self.logger.start_session(sim_config.to_dict())
@ -103,15 +117,18 @@ class GameEngine:
self.is_running = True
def initialize(self, num_agents: Optional[int] = None) -> None:
"""Initialize the simulation with agents."""
# Reset faction relations
reset_faction_relations()
"""Initialize the simulation with agents.
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())
@ -126,6 +143,7 @@ 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,
@ -133,14 +151,16 @@ 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()]
# Remove old corpses
# 0. Remove corpses from previous turn (agents who died last turn)
self._remove_old_corpses(current_turn)
# Collect AI decisions
# 1. Collect AI decisions for all living agents (not corpses)
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,
@ -152,24 +172,27 @@ 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())
# Calculate movement
# 2. Calculate movement targets and move agents
for agent, decision in decisions:
action_name = decision.action.value
agent.set_action(
@ -178,16 +201,35 @@ 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()
# Execute actions
# 3. Execute all actions and update action indicators with results
for agent, decision in decisions:
result = self._execute_action(agent, decision, turn_log)
result = self._execute_action(agent, decision)
# Complete agent action with result - this updates the indicator to show what was done
if result:
agent.complete_action(result.success, result.message)
# Log to agent's personal history
agent.log_action(
turn=current_turn,
action_type=decision.action.value,
result=result.message,
success=result.success,
)
# Track resources produced
for res in result.resources_gained:
res_type = res.type.value
turn_log.resources_produced[res_type] = turn_log.resources_produced.get(res_type, 0) + res.quantity
self.resource_stats["produced"][res_type] = self.resource_stats["produced"].get(res_type, 0) + res.quantity
# Track resources consumed
for res in result.resources_consumed:
res_type = res.type.value
turn_log.resources_consumed[res_type] = turn_log.resources_consumed.get(res_type, 0) + res.quantity
self.resource_stats["consumed"][res_type] = self.resource_stats["consumed"].get(res_type, 0) + res.quantity
turn_log.agent_actions.append({
"agent_id": agent.id,
@ -196,6 +238,7 @@ 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(),
@ -205,35 +248,45 @@ class GameEngine:
action_result=result.to_dict() if result else {},
)
# Update market prices
# 4. Resolve pending market orders (price updates)
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)
# Apply passive decay
# 5. Apply passive decay to all living agents
for agent in self.world.get_living_agents():
agent.apply_passive_decay()
# Decay resources
# 6. Decay resources in inventories
for agent in self.world.get_living_agents():
expired = agent.decay_inventory(current_turn)
# Track spoiled resources
for res in expired:
res_type = res.type.value
turn_log.resources_spoiled[res_type] = turn_log.resources_spoiled.get(res_type, 0) + res.quantity
self.resource_stats["spoiled"][res_type] = self.resource_stats["spoiled"].get(res_type, 0) + res.quantity
# Mark dead agents
# 7. Mark newly dead agents as corpses (don't remove yet for visualization)
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()
# Advance time
# 8. Advance time
self.world.advance_time()
# Check end conditions
# 9. Check win/lose conditions (count only truly living agents, not corpses)
if len(self.world.get_living_agents()) == 0:
self.is_running = False
self.logger.close()
@ -242,12 +295,14 @@ class GameEngine:
return turn_log
def _mark_dead_agents(self, current_turn: int) -> list[Agent]:
"""Mark agents who just died as corpses."""
"""Mark agents who just died as corpses. Returns list of newly dead agents."""
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)
@ -258,6 +313,7 @@ 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:
@ -266,12 +322,12 @@ class GameEngine:
return to_remove
def _execute_action(self, agent: Agent, decision: AIDecision, turn_log: TurnLog) -> Optional[ActionResult]:
def _execute_action(self, agent: Agent, decision: AIDecision) -> Optional[ActionResult]:
"""Execute an action for an agent."""
action = decision.action
config = ACTION_CONFIG[action]
# Basic actions
# Handle different action types
if action == ActionType.SLEEP:
agent.restore_energy(config.energy_cost)
return ActionResult(
@ -293,9 +349,17 @@ class GameEngine:
elif action == ActionType.CONSUME:
if decision.target_resource:
success = agent.consume(decision.target_resource)
consumed_list = []
if success:
consumed_list.append(Resource(
type=decision.target_resource,
quantity=1,
created_turn=self.world.current_turn,
))
return ActionResult(
action_type=action,
success=success,
resources_consumed=consumed_list,
message=f"Consumed {decision.target_resource.value}" if success else "Nothing to consume",
)
return ActionResult(action_type=action, success=False, message="No resource specified")
@ -305,6 +369,8 @@ 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(
@ -312,60 +378,30 @@ class GameEngine:
success=True,
energy_spent=abs(config.energy_cost),
heat_gained=fire_heat,
resources_consumed=[Resource(type=ResourceType.WOOD, quantity=1, created_turn=self.world.current_turn)],
message="Built a warm fire",
)
return ActionResult(action_type=action, success=False, message="No wood for fire")
elif action == ActionType.BURN_FUEL:
if agent.has_resource(ResourceType.FUEL):
agent.remove_from_inventory(ResourceType.FUEL, 1)
if not agent.spend_energy(abs(config.energy_cost)):
return ActionResult(action_type=action, success=False, message="Not enough energy")
fuel_heat = get_fuel_heat()
agent.apply_heat(fuel_heat)
# Fuel also provides energy
from backend.config import get_config
fuel_energy = get_config().resources.fuel_energy
agent.restore_energy(fuel_energy)
return ActionResult(
action_type=action,
success=True,
energy_spent=abs(config.energy_cost),
heat_gained=fuel_heat,
message=f"Burned fuel (+{fuel_heat} heat, +{fuel_energy} energy)",
)
return ActionResult(action_type=action, success=False, message="No fuel to burn")
elif action == ActionType.TRADE:
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.DRILL_OIL,
ActionType.REFINE]:
ActionType.GET_WATER, ActionType.WEAVE]:
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, drilling, etc.)."""
"""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
energy_cost = abs(config.energy_cost)
if not agent.spend_energy(energy_cost):
return ActionResult(
@ -374,32 +410,36 @@ class GameEngine:
message="Not enough energy",
)
# Check required materials
resources_consumed = []
if config.requires_resource:
if not agent.has_resource(config.requires_resource, config.requires_quantity):
agent.restore_energy(energy_cost)
agent.restore_energy(energy_cost) # Refund energy
return ActionResult(
action_type=action,
success=False,
message=f"Missing required {config.requires_resource.value}",
)
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
resources_consumed.append(Resource(
type=config.requires_resource,
quantity=config.requires_quantity,
created_turn=self.world.current_turn,
))
# Get skill modifier
# Get relevant skill for this action
skill_name = self._get_skill_for_action(action)
skill_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0
skill_modifier = get_action_skill_modifier(skill_value)
# 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)
# Check success chance (modified by skill)
# Higher skill = higher effective success chance
effective_success_chance = min(0.98, config.success_chance * skill_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)
agent.skills.improve(skill_name, 0.005) # Small improvement on failure
return ActionResult(
action_type=action,
success=False,
@ -407,11 +447,13 @@ 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 * total_modifier))
quantity = max(config.min_output, int(base_quantity * skill_modifier))
if quantity > 0:
resource = Resource(
@ -427,9 +469,10 @@ 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 * total_modifier))
quantity = max(0, int(base_quantity * skill_modifier))
if quantity > 0:
resource = Resource(
type=config.secondary_output,
@ -444,10 +487,12 @@ 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)
agent.skills.improve(skill_name, 0.015) # Skill improves with successful use
# 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)"
@ -456,243 +501,37 @@ class GameEngine:
success=True,
energy_spent=energy_cost,
resources_gained=resources_gained,
resources_consumed=resources_consumed,
message=message,
)
def _get_skill_for_action(self, action: ActionType) -> Optional[str]:
"""Get the skill name for an action."""
"""Get the skill name that affects a given 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_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")
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."""
"""Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades.
Trading skill improves with successful trades and affects prices slightly.
"""
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,
@ -701,8 +540,10 @@ 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,
@ -710,8 +551,14 @@ class GameEngine:
self.world.current_turn,
)
# Track traded resources
res_type = result.resource_type.value
self.resource_stats["traded"][res_type] = self.resource_stats["traded"].get(res_type, 0) + result.quantity
# Deduct money from buyer
agent.money -= result.total_paid
# Add resources to buyer
resource = Resource(
type=result.resource_type,
quantity=result.quantity,
@ -719,25 +566,18 @@ 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)
# 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
)
seller.skills.improve("trading", 0.02) # Seller skill improves
agent.spend_energy(abs(config.energy_cost))
# Record buyer's trade and improve skill
agent.record_action("trade")
agent.skills.improve("trading", 0.01)
agent.skills.improve("trading", 0.01) # Buyer skill improves less
return ActionResult(
action_type=ActionType.TRADE,
@ -754,6 +594,7 @@ 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)
@ -766,7 +607,7 @@ class GameEngine:
)
agent.spend_energy(abs(config.energy_cost))
agent.record_action("trade")
agent.record_action("trade") # Track listing action
return ActionResult(
action_type=ActionType.TRADE,
@ -788,7 +629,7 @@ class GameEngine:
)
def _execute_price_adjustment(self, agent: Agent, decision: AIDecision) -> ActionResult:
"""Execute a price adjustment."""
"""Execute a price adjustment on an existing order (no energy cost)."""
success = self.market.adjust_order_price(
order_id=decision.adjust_order_id,
seller_id=agent.id,
@ -800,8 +641,8 @@ class GameEngine:
return ActionResult(
action_type=ActionType.TRADE,
success=True,
energy_spent=0,
message=f"Adjusted price to {decision.new_price}c",
energy_spent=0, # Price adjustments are free
message=f"Adjusted {decision.target_resource.value} price to {decision.new_price}c",
)
else:
return ActionResult(
@ -814,13 +655,17 @@ 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 = []
@ -831,6 +676,7 @@ 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,
@ -838,6 +684,10 @@ class GameEngine:
self.world.current_turn
)
# Track traded resources
res_type = result.resource_type.value
self.resource_stats["traded"][res_type] = self.resource_stats["traded"].get(res_type, 0) + result.quantity
resource = Resource(
type=result.resource_type,
quantity=result.quantity,
@ -847,6 +697,7 @@ 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
@ -909,9 +760,35 @@ class GameEngine:
"recent_logs": [
log.to_dict() for log in self.turn_logs[-5:]
],
"resource_stats": self._get_resource_stats(),
}
def _get_resource_stats(self) -> dict:
"""Get comprehensive resource statistics."""
# Calculate current inventory totals
in_inventory = {}
for agent in self.world.get_living_agents():
for res in agent.inventory:
res_type = res.type.value
in_inventory[res_type] = in_inventory.get(res_type, 0) + res.quantity
# Calculate current market totals
in_market = {}
for order in self.market.get_active_orders():
res_type = order.resource_type.value
in_market[res_type] = in_market.get(res_type, 0) + order.quantity
return {
"produced": self.resource_stats["produced"].copy(),
"consumed": self.resource_stats["consumed"].copy(),
"spoiled": self.resource_stats["spoiled"].copy(),
"traded": self.resource_stats["traded"].copy(),
"in_inventory": in_inventory,
"in_market": in_market,
}
# Global engine instance
def get_engine() -> GameEngine:
"""Get the global game engine instance."""
return GameEngine()

View File

@ -3,9 +3,6 @@
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
@ -18,13 +15,6 @@ 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):
@ -41,14 +31,16 @@ def _get_world_config_from_file():
@dataclass
class WorldConfig:
"""Configuration for the world."""
"""Configuration for the world.
Default values are loaded from config.json via create_world_config().
These hardcoded defaults are only fallbacks.
"""
width: int = 25
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:
@ -60,21 +52,9 @@ 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."""
@ -85,46 +65,9 @@ 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,
@ -133,8 +76,6 @@ 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.
@ -144,8 +85,6 @@ 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(
@ -157,38 +96,25 @@ 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,
profession=Profession.VILLAGER, # Will be updated based on personality
position=position,
personality=personality,
skills=skills,
religion=religion,
diplomacy=faction,
money=starting_money,
)
@ -203,35 +129,12 @@ 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."""
"""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.
"""
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:
@ -245,14 +148,12 @@ 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
@ -266,25 +167,13 @@ class World:
living = self.get_living_agents()
total_money = sum(a.money for a in living)
# Count emergent professions
# Count emergent professions (updated based on current skills)
profession_counts = {}
for agent in living:
agent._update_profession()
agent._update_profession() # Update based on current state
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])
@ -293,21 +182,15 @@ class World:
richest = moneys[-1] if moneys else 0
poorest = moneys[0] if moneys else 0
# Gini coefficient
# Gini coefficient for inequality (0 = perfect equality, 1 = max inequality)
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 = avg_faith = 0
# War status
active_wars = len(self.faction_relations.active_wars)
avg_money = median_money = richest = poorest = gini = 0
return {
"current_turn": self.current_turn,
@ -319,20 +202,12 @@ class World:
"total_agents_died": self.total_agents_died,
"total_money_in_circulation": total_money,
"professions": profession_counts,
# Wealth metrics
# Wealth inequality 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:
@ -345,34 +220,21 @@ 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.
Now also seeds religious and faction diversity.
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)
"""
# 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) +
@ -380,33 +242,13 @@ 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,8 +3,6 @@
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",
@ -17,11 +15,5 @@ __all__ = [
"Agent",
"AgentStats",
"Position",
# Religion
"ReligionType",
"ReligiousBeliefs",
# Diplomacy
"FactionType",
"AgentDiplomacy",
"FactionRelations",
]

View File

@ -15,7 +15,6 @@ 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
@ -27,20 +26,6 @@ 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:
@ -55,13 +40,14 @@ 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."""
"""Get action configurations from the global config.
This function dynamically builds ACTION_CONFIG from config.json values.
"""
# Import here to avoid circular imports
from backend.config import get_config
config = get_config()
@ -69,10 +55,10 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
return {
ActionType.SLEEP: ActionConfig(
energy_cost=actions.sleep_energy,
energy_cost=actions.sleep_energy, # Restores energy
),
ActionType.REST: ActionConfig(
energy_cost=actions.rest_energy,
energy_cost=actions.rest_energy, # Restores some energy
),
ActionType.HUNT: ActionConfig(
energy_cost=actions.hunt_energy,
@ -126,53 +112,6 @@ 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
),
}
@ -194,6 +133,8 @@ 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."""
@ -220,21 +161,6 @@ 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."""
@ -244,14 +170,8 @@ 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 {
@ -267,9 +187,5 @@ 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,9 +3,6 @@
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
@ -20,8 +17,6 @@ from .personality import (
PersonalityTraits, Skills, ProfessionType,
determine_profession
)
from .religion import ReligiousBeliefs
from .diplomacy import AgentDiplomacy
def _get_agent_stats_config():
@ -38,8 +33,6 @@ class Profession(Enum):
WOODCUTTER = "woodcutter"
TRADER = "trader"
CRAFTER = "crafter"
OIL_WORKER = "oil_worker" # NEW: Oil industry worker
PRIEST = "priest" # NEW: Religious leader
@dataclass
@ -87,21 +80,18 @@ 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)
@ -116,9 +106,6 @@ 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)
@ -148,31 +135,16 @@ 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,
}
@ -184,17 +156,14 @@ 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,
)
@ -202,10 +171,9 @@ def create_agent_stats() -> AgentStats:
@dataclass
class AgentAction:
"""Current action being performed by an agent."""
action_type: str = "" # e.g., "hunt", "gather", "trade", "rest", "pray"
action_type: str = "" # e.g., "hunt", "gather", "trade", "rest"
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 = ""
@ -215,7 +183,6 @@ 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,
@ -224,27 +191,16 @@ 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)},
"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)},
"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)
"build_fire": {"zone": "village", "offset_range": (0.45, 0.55)},
"burn_fuel": {"zone": "village", "offset_range": (0.45, 0.55)},
"trade": {"zone": "market", "offset_range": (0.5, 0.6)},
"trade": {"zone": "market", "offset_range": (0.5, 0.6)}, # Center (market)
"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)},
# 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)},
"consume": {"zone": "current", "offset_range": (0, 0)}, # Stay in place
}
@ -261,76 +217,61 @@ 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
profession: Profession = Profession.VILLAGER # Now derived from personality/skills
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)
money: int = field(default=-1) # -1 signals to use config value
# Personality and skills
# Personality and skills - create agent diversity
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
death_reason: str = ""
death_turn: int = -1 # Turn when agent died, -1 if alive
death_reason: str = "" # Cause of death
# Statistics tracking for profession determination
actions_performed: dict = field(default_factory=lambda: {
"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,
"hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0
})
total_trades_completed: int = 0
total_money_earned: int = 0
# Personal action log (recent actions with results)
action_history: list = field(default_factory=list)
MAX_HISTORY_SIZE: int = 20
# Configuration - loaded from config
INVENTORY_SLOTS: int = field(default=-1)
MOVE_SPEED: float = 0.8
INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value
MOVE_SPEED: float = 0.8 # Grid cells per turn
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, 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
"""Update profession based on personality and skills."""
prof_type = determine_profession(self.personality, self.skills)
profession_map = {
ProfessionType.HUNTER: Profession.HUNTER,
@ -346,6 +287,19 @@ class Agent:
if action_type in self.actions_performed:
self.actions_performed[action_type] += 1
def log_action(self, turn: int, action_type: str, result: str, success: bool = True) -> None:
"""Add an action to the agent's personal history log."""
entry = {
"turn": turn,
"action": action_type,
"result": result,
"success": success,
}
self.action_history.append(entry)
# Keep only recent history
if len(self.action_history) > self.MAX_HISTORY_SIZE:
self.action_history = self.action_history[-self.MAX_HISTORY_SIZE:]
def record_trade(self, money_earned: int) -> None:
"""Record a completed trade for statistics."""
self.total_trades_completed += 1
@ -361,7 +315,7 @@ class Agent:
)
def is_corpse(self) -> bool:
"""Check if this agent is a corpse."""
"""Check if this agent is a corpse (died but still visible)."""
return self.death_turn >= 0
def can_act(self) -> bool:
@ -372,14 +326,6 @@ 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)
@ -396,20 +342,22 @@ 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]) if offset_range else 0.0
offset_max = float(offset_range[1]) if offset_range else 0.0
offset_min = float(offset_range[0])
offset_max = float(offset_range[1])
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))
@ -420,7 +368,6 @@ 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,
@ -435,7 +382,7 @@ class Agent:
)
if reached:
self.current_action.is_moving = False
self.current_action.progress = 0.5
self.current_action.progress = 0.5 # At location, doing action
def complete_action(self, success: bool, message: str) -> None:
"""Mark current action as complete."""
@ -452,11 +399,13 @@ 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,
@ -520,7 +469,7 @@ class Agent:
return True
def apply_heat(self, amount: int) -> None:
"""Apply heat from a fire or fuel."""
"""Apply heat from a fire."""
self.stats.heat = min(self.stats.MAX_HEAT, self.stats.heat + amount)
def restore_energy(self, amount: int) -> None:
@ -534,13 +483,8 @@ 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."""
"""Remove expired resources from inventory. Returns list of removed resources."""
expired = []
for resource in self.inventory[:]:
if resource.is_expired(current_turn):
@ -551,38 +495,15 @@ 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 {
@ -601,13 +522,12 @@ class Agent:
"last_action_result": self.last_action_result,
"death_turn": self.death_turn,
"death_reason": self.death_reason,
# Personality and skills
# New fields for agent diversity
"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(),
# Personal action history
"action_history": self.action_history.copy(),
}

View File

@ -1,515 +0,0 @@
"""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

View File

@ -1,337 +0,0 @@
"""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,9 +19,6 @@ 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
@ -35,6 +32,7 @@ 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()
@ -55,19 +53,12 @@ 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()
@ -80,9 +71,6 @@ 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,
}
@ -92,12 +80,6 @@ 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
@ -158,37 +140,6 @@ 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."""
@ -206,16 +157,6 @@ 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:
@ -236,5 +177,4 @@ class Resource:
"quantity": self.quantity,
"created_turn": self.created_turn,
"decay_rate": self.decay_rate,
"base_value": self.base_value,
}

View File

@ -1,12 +1,17 @@
"""FastAPI entry point for the Village Simulation backend."""
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from backend.api.routes import router
from backend.core.engine import get_engine
# Path to web frontend
WEB_FRONTEND_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "web_frontend")
# Create FastAPI app
app = FastAPI(
title="Village Simulation API",
@ -48,6 +53,7 @@ def root():
"name": "Village Simulation API",
"version": "1.0.0",
"docs": "/docs",
"web_frontend": "/web/",
"status": "running",
}
@ -63,6 +69,14 @@ def health_check():
}
# ============== Web Frontend Static Files ==============
# Mount static files for web frontend
# Access at http://localhost:8000/web/
if os.path.exists(WEB_FRONTEND_PATH):
app.mount("/web", StaticFiles(directory=WEB_FRONTEND_PATH, html=True), name="web_frontend")
def main():
"""Run the server."""
uvicorn.run(

View File

@ -1,118 +1,73 @@
{
"agent_stats": {
"max_energy": 60,
"max_energy": 50,
"max_hunger": 100,
"max_thirst": 100,
"max_heat": 100,
"max_faith": 100,
"start_energy": 60,
"start_hunger": 90,
"start_thirst": 90,
"start_energy": 50,
"start_hunger": 70,
"start_thirst": 75,
"start_heat": 100,
"start_faith": 45,
"energy_decay": 1,
"hunger_decay": 1,
"thirst_decay": 2,
"heat_decay": 2,
"faith_decay": 1,
"critical_threshold": 0.18,
"hunger_decay": 2,
"thirst_decay": 3,
"heat_decay": 3,
"critical_threshold": 0.25,
"low_energy_threshold": 12
},
"resources": {
"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
"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
},
"actions": {
"sleep_energy": 55,
"rest_energy": 15,
"hunt_energy": -5,
"gather_energy": -2,
"chop_wood_energy": -4,
"rest_energy": 12,
"hunt_energy": -7,
"gather_energy": -3,
"chop_wood_energy": -6,
"get_water_energy": -2,
"weave_energy": -4,
"build_fire_energy": -3,
"weave_energy": -6,
"build_fire_energy": -4,
"trade_energy": -1,
"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,
"hunt_success": 0.70,
"chop_wood_success": 0.90,
"drill_oil_success": 0.70,
"hunt_meat_min": 3,
"hunt_meat_max": 6,
"hunt_meat_min": 2,
"hunt_meat_max": 5,
"hunt_hide_min": 0,
"hunt_hide_max": 2,
"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
"gather_min": 2,
"gather_max": 4,
"chop_wood_min": 1,
"chop_wood_max": 3
},
"world": {
"width": 50,
"height": 50,
"initial_agents": 100,
"width": 25,
"height": 25,
"initial_agents": 25,
"day_steps": 10,
"night_steps": 1,
"inventory_slots": 14,
"starting_money": 100,
"oil_fields_count": 5,
"temple_count": 5
"inventory_slots": 12,
"starting_money": 80
},
"market": {
"turns_before_discount": 10,
"discount_rate": 0.08,
"base_price_multiplier": 1.15
"turns_before_discount": 15,
"discount_rate": 0.12,
"base_price_multiplier": 1.3
},
"economy": {
"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
"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
},
"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
"auto_step_interval": 0.15
}

View File

@ -1,10 +1,7 @@
"""HTTP client for communicating with the Village Simulation backend.
Handles state including religion, factions, diplomacy, and oil economy.
"""
"""HTTP client for communicating with the Village Simulation backend."""
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Optional, Any
import requests
@ -28,15 +25,6 @@ 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."""
@ -54,75 +42,12 @@ 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."""
@ -157,7 +82,7 @@ class SimulationClient:
self.connected = True
self._retry_count = 0
return response.json()
except RequestException:
except RequestException as e:
self._retry_count += 1
if self._retry_count >= self._max_retries:
self.connected = False
@ -182,7 +107,7 @@ class SimulationClient:
if data:
self.last_state = SimulationState.from_api_response(data)
return self.last_state
return self.last_state
return self.last_state # Return cached state if request failed
def advance_turn(self) -> bool:
"""Advance the simulation by one step."""
@ -196,9 +121,9 @@ class SimulationClient:
def initialize(
self,
num_agents: int = 100,
world_width: int = 30,
world_height: int = 30,
num_agents: int = 8,
world_width: int = 20,
world_height: int = 20,
) -> bool:
"""Initialize or reset the simulation."""
result = self._request("POST", "/control/initialize", json={
@ -252,3 +177,4 @@ 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,7 +1,4 @@
"""Main Pygame application for the Village Simulation frontend.
Redesigned for fullscreen, 100+ agents, with religion, factions, and diplomacy.
"""
"""Main Pygame application for the Village Simulation frontend."""
import sys
import pygame
@ -15,13 +12,14 @@ from frontend.renderer.stats_renderer import StatsRenderer
# Window configuration
WINDOW_TITLE = "Village Simulation - Economy, Religion & Diplomacy"
FPS = 60
WINDOW_WIDTH = 1200
WINDOW_HEIGHT = 800
WINDOW_TITLE = "Village Economy Simulation"
FPS = 30
# Layout ratios (will scale with screen)
TOP_PANEL_HEIGHT_RATIO = 0.06
RIGHT_PANEL_WIDTH_RATIO = 0.22
BOTTOM_PANEL_HEIGHT_RATIO = 0.08
# Layout configuration
TOP_PANEL_HEIGHT = 50
RIGHT_PANEL_WIDTH = 200
class VillageSimulationApp:
@ -32,60 +30,31 @@ class VillageSimulationApp:
pygame.init()
pygame.font.init()
# 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
)
# Create window
self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
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()
# 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)
# Fonts
self.font = pygame.font.Font(None, 24)
# Network client
self.client = SimulationClient(server_url)
# Calculate map area (left side, between top and bottom panels)
# Calculate map area
self.map_rect = pygame.Rect(
0,
self.top_panel_height,
self.screen_width - self.right_panel_width,
self.screen_height - self.top_panel_height - self.bottom_panel_height,
TOP_PANEL_HEIGHT,
WINDOW_WIDTH - RIGHT_PANEL_WIDTH,
WINDOW_HEIGHT - TOP_PANEL_HEIGHT,
)
# Initialize renderers with screen dimensions
# Initialize renderers
self.map_renderer = MapRenderer(self.screen, self.map_rect)
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.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font)
self.ui_renderer = UIRenderer(self.screen, self.font)
self.settings_renderer = SettingsRenderer(self.screen)
self.stats_renderer = StatsRenderer(self.screen)
@ -93,25 +62,26 @@ class VillageSimulationApp:
self.state: SimulationState | None = None
self.running = True
self.hovered_agent: dict | None = None
self._last_turn: int = -1
self._last_turn: int = -1 # Track turn changes for stats update
# Polling interval (ms)
self.last_poll_time = 0
self.poll_interval = 50 # Poll every 50ms for smoother updates
self.poll_interval = 100 # Poll every 100ms 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):
num_agents = config.get("world", {}).get("initial_agents", 100)
if self.client.initialize(num_agents=num_agents):
# Restart simulation with new config
if self.client.initialize():
self.state = self.client.get_state()
self.settings_renderer.status_message = "Config applied & simulation restarted!"
self.settings_renderer.status_color = (80, 180, 100)
@ -124,6 +94,7 @@ 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)
@ -176,12 +147,13 @@ 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(num_agents=100):
if self.client.initialize():
self.state = self.client.get_state()
self.stats_renderer.clear_history()
self._last_turn = -1
@ -205,10 +177,6 @@ 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:
@ -222,7 +190,7 @@ class VillageSimulationApp:
if not self.map_rect.collidepoint(mouse_pos):
return
# Check each agent (only check visible ones for performance)
# Check each agent
for agent in self.state.agents:
if not agent.get("is_alive", False):
continue
@ -274,8 +242,8 @@ class VillageSimulationApp:
def draw(self) -> None:
"""Draw all elements."""
# Clear screen with dark background
self.screen.fill((15, 17, 23))
# Clear screen
self.screen.fill((30, 35, 45))
if self.state:
# Draw map
@ -284,7 +252,7 @@ class VillageSimulationApp:
# Draw agents
self.agent_renderer.draw(self.state)
# Draw UI panels
# Draw UI
self.ui_renderer.draw(self.state)
# Draw agent tooltip if hovering
@ -304,22 +272,16 @@ class VillageSimulationApp:
# Draw hints at bottom
if not self.settings_renderer.visible and not self.stats_renderer.visible:
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))
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))
# Update display
pygame.display.flip()
def run(self) -> None:
"""Main game loop."""
print("=" * 60)
print(" VILLAGE SIMULATION - Economy, Religion & Diplomacy")
print("=" * 60)
print(f"\nScreen: {self.screen_width}x{self.screen_height} (Fullscreen)")
print("Starting Village Simulation Frontend...")
print("Connecting to backend at http://localhost:8000...")
# Try to connect initially
@ -330,13 +292,12 @@ class VillageSimulationApp:
self.state = self.client.get_state()
print("\nControls:")
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(" 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()
while self.running:
@ -350,6 +311,7 @@ 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,13 +4,6 @@ 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",
"StatsRenderer",
]
__all__ = ["MapRenderer", "AgentRenderer", "UIRenderer", "SettingsRenderer"]

View File

@ -1,7 +1,4 @@
"""Agent renderer for the Village Simulation.
Optimized for 100+ agents with faction/religion color coding.
"""
"""Agent renderer for the Village Simulation."""
import math
import pygame
@ -12,53 +9,42 @@ if TYPE_CHECKING:
from frontend.renderer.map_renderer import MapRenderer
# 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
# 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
}
# Corpse color
CORPSE_COLOR = (50, 50, 55)
CORPSE_COLOR = (60, 60, 60) # Dark gray
# Action symbols (simplified for performance)
ACTION_SYMBOLS = {
"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": "",
# 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
}
# Fallback ASCII
# Action icons/symbols
ACTION_SYMBOLS = {
"hunt": "🏹",
"gather": "🍇",
"chop_wood": "🪓",
"get_water": "💧",
"weave": "🧵",
"build_fire": "🔥",
"trade": "💰",
"rest": "💤",
"sleep": "😴",
"consume": "🍖",
"dead": "💀",
}
# Fallback ASCII symbols for systems without emoji support
ACTION_LETTERS = {
"hunt": "H",
"gather": "G",
@ -70,20 +56,12 @@ 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 faction/religion indicators."""
"""Renders agents on the map with movement and action indicators."""
def __init__(
self,
@ -94,116 +72,89 @@ class AgentRenderer:
self.screen = screen
self.map_renderer = map_renderer
self.font = font
self.small_font = pygame.font.Font(None, 14)
self.action_font = pygame.font.Font(None, 16)
self.tooltip_font = pygame.font.Font(None, 18)
self.small_font = pygame.font.Font(None, 16)
self.action_font = pygame.font.Font(None, 20)
# 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 main color for an agent (faction-based)."""
"""Get the color for an agent based on state."""
# Corpses are dark gray
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
return CORPSE_COLOR
base_color = self._get_faction_color(agent)
profession = agent.get("profession", "villager")
base_color = PROFESSION_COLORS.get(profession, (100, 140, 180))
if not agent.get("can_act", True):
# Dimmed for exhausted agents
return tuple(int(c * 0.6) for c in base_color)
# Slightly dimmed for exhausted agents
return tuple(int(c * 0.7) for c in base_color)
return base_color
def _draw_mini_bar(
def _draw_status_bar(
self,
x: int,
y: int,
width: int,
height: int,
value: float,
max_value: float,
value: int,
max_value: int,
color: tuple[int, int, int],
) -> None:
"""Draw a tiny status bar."""
if max_value <= 0:
return
"""Draw a single status bar."""
# Background
pygame.draw.rect(self.screen, (25, 25, 30), (x, y, width, height))
pygame.draw.rect(self.screen, (40, 40, 40), (x, y, width, height))
# Fill
fill_width = int((value / max_value) * width)
fill_width = int((value / max_value) * width) if max_value > 0 else 0
if fill_width > 0:
# 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))
pygame.draw.rect(self.screen, color, (x, y, fill_width, height))
def _draw_status_bars(
self,
agent: dict,
center_x: int,
center_y: int,
size: int
) -> None:
"""Draw compact status bars below the agent."""
# 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."""
stats = agent.get("stats", {})
bar_width = size + 6
bar_height = 2
bar_spacing = 3
start_y = center_y + size // 2 + 3
bar_width = size + 10
bar_height = 3
bar_spacing = 4
start_y = center_y + size // 2 + 4
bars = [
(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)),
("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)),
]
for i, (value, max_value, color) in enumerate(bars):
for i, (stat_name, value, max_value) in enumerate(bars):
bar_y = start_y + i * bar_spacing
self._draw_mini_bar(
self._draw_status_bar(
center_x - bar_width // 2,
bar_y,
bar_width,
bar_height,
value,
max_value,
color,
BAR_COLORS[stat_name],
)
def _draw_action_bubble(
def _draw_action_indicator(
self,
agent: dict,
center_x: int,
center_y: int,
agent_size: int,
) -> None:
"""Draw action indicator bubble above agent."""
"""Draw action indicator above the 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
@ -211,191 +162,170 @@ class AgentRenderer:
# Get action symbol
symbol = ACTION_LETTERS.get(action_type, "?")
# Position above agent
bubble_y = center_y - agent_size // 2 - 12
# Draw action bubble above agent
bubble_y = center_y - agent_size // 2 - 20
# Animate if moving
is_moving = current_action.get("is_moving", False)
if is_moving:
offset = int(2 * math.sin(self.animation_tick * 0.3))
# Bouncing animation
offset = int(3 * math.sin(self.animation_tick * 0.3))
bubble_y += offset
# Draw small bubble
bubble_w, bubble_h = 14, 12
# Draw bubble background
bubble_width = 22
bubble_height = 18
bubble_rect = pygame.Rect(
center_x - bubble_w // 2,
bubble_y - bubble_h // 2,
bubble_w,
bubble_h,
center_x - bubble_width // 2,
bubble_y - bubble_height // 2,
bubble_width,
bubble_height,
)
# 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)
# Color based on action success/failure
if "Failed" in message:
bg_color = (120, 60, 60)
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 = (50, 60, 80)
bg_color = (60, 80, 120)
border_color = (100, 140, 200)
else:
bg_color = (40, 55, 45)
border_color = (80, 130, 90)
bg_color = (50, 70, 50)
border_color = (80, 140, 80)
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)
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)
# Draw symbol
text = self.action_font.render(symbol, True, (230, 230, 230))
# Draw action letter
text = self.action_font.render(symbol, True, (255, 255, 255))
text_rect = text.get_rect(center=(center_x, bubble_y))
self.screen.blit(text, text_rect)
def _draw_religion_indicator(
# 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(
self,
agent: dict,
center_x: int,
center_y: int,
agent_size: int,
) -> None:
"""Draw a small religion indicator (faith glow)."""
faith = agent.get("faith", 50)
religion_color = self._get_religion_color(agent)
"""Draw the last action result as floating text."""
result = agent.get("last_action_result", "")
if not result:
return
# 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),
)
# Truncate long messages
if len(result) > 25:
result = result[: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)
# Draw text below status bars
text_y = center_y + agent_size // 2 + 22
def _draw_war_indicator(self, agent: dict, center_x: int, center_y: int) -> None:
"""Draw indicator if agent's faction is at war."""
diplomacy = agent.get("diplomacy", {})
faction = diplomacy.get("faction", "neutral")
at_war = agent.get("at_war", False)
text = self.small_font.render(result, True, (180, 180, 180))
text_rect = text.get_rect(center=(center_x, text_y))
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,
)
# 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)
def draw(self, state: "SimulationState") -> None:
"""Draw all agents (optimized for many agents)."""
"""Draw all agents (including corpses for one turn)."""
self.animation_tick += 1
cell_w, cell_h = self.map_renderer.get_cell_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 = []
agent_size = min(cell_w, cell_h) - 8
agent_size = max(10, min(agent_size, 30)) # Clamp size
for agent in state.agents:
if agent.get("is_corpse", False):
corpses.append(agent)
elif agent.get("is_alive", True):
living.append(agent)
is_corpse = agent.get("is_corpse", False)
is_alive = agent.get("is_alive", True)
# 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:
# Get screen position from agent's current position
pos = agent.get("position", {"x": 0, "y": 0})
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
# Religion glow (full detail only)
if self.detail_level >= 2:
self._draw_religion_indicator(agent, screen_x, screen_y, agent_size)
if is_corpse:
# Draw corpse with death indicator
self._draw_corpse(agent, screen_x, screen_y, agent_size)
continue
# Action bubble (basic+ detail)
if self.detail_level >= 1:
self._draw_action_bubble(agent, screen_x, screen_y, agent_size)
if not is_alive:
continue
# Main agent circle with faction color
# Draw movement trail/line to target first (behind agent)
self._draw_action_indicator(agent, screen_x, screen_y, agent_size)
# Draw agent circle
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)
# Border - based on state
# Draw border - animated if moving
current_action = agent.get("current_action", {})
is_moving = current_action.get("is_moving", False)
if is_moving:
pulse = int(180 + 75 * math.sin(self.animation_tick * 0.2))
# Pulsing border when moving
pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2))
border_color = (pulse, pulse, 255)
elif agent.get("can_act"):
border_color = (200, 200, 210)
border_color = (255, 255, 255)
else:
border_color = (80, 80, 85)
border_color = (100, 100, 100)
pygame.draw.circle(
self.screen, border_color,
(screen_x, screen_y),
agent_size // 2,
1,
)
pygame.draw.circle(self.screen, border_color, (screen_x, screen_y), agent_size // 2, 2)
# Money indicator
# Draw money indicator (small coin icon)
money = agent.get("money", 0)
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)
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)
# War indicator
if self.detail_level >= 1:
self._draw_war_indicator(agent, screen_x, screen_y)
# 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)
# Status bars (basic+ detail)
if self.detail_level >= 1:
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
# 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)
def _draw_corpse(
self,
@ -404,117 +334,97 @@ class AgentRenderer:
center_y: int,
agent_size: int,
) -> None:
"""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 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)
# 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,
)
# 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)
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
"""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)}")
"""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",
"",
]
# Current action
current_action = agent.get("current_action", {})
action_type = current_action.get("action_type", "")
if action_type:
lines.append("")
lines.append(f"Action: {action_type.replace('_', ' ').title()}")
action_msg = current_action.get("message", action_type)
lines.append(f"Action: {action_msg[:40]}")
if current_action.get("is_moving"):
lines.append(" (moving)")
lines.append(" (moving to location)")
lines.append("")
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[: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")
for item in inventory[:5]:
lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}")
# Calculate size
# 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
line_height = 16
max_width = max(self.tooltip_font.size(line)[0] for line in lines if line) + 24
height = len(lines) * line_height + 16
max_width = max(self.small_font.size(line)[0] for line in lines) + 20
height = len(lines) * line_height + 10
# 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)
# 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)
# Background with faction color accent
# Draw background
tooltip_rect = pygame.Rect(x, y, max_width, height)
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,
)
pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect)
pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1)
# Draw text
for i, line in enumerate(lines):
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))
text = self.small_font.render(line, True, (220, 220, 220))
self.screen.blit(text, (x + 10, y + 5 + i * line_height))

View File

@ -1,10 +1,5 @@
"""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
@ -12,58 +7,29 @@ if TYPE_CHECKING:
from frontend.client import SimulationState
# Color palette - Cyberpunk dark theme
# Color palette
class Colors:
# Background colors
DAY_BG = (28, 35, 42)
NIGHT_BG = (12, 14, 20)
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)
# 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
}
# Terrain features (for visual variety)
GRASS_LIGHT = (160, 190, 140)
GRASS_DARK = (140, 170, 120)
WATER_SPOT = (100, 140, 180)
class MapRenderer:
"""Renders the map/terrain background with special locations."""
"""Renders the map/terrain background."""
def __init__(
self,
screen: pygame.Surface,
map_rect: pygame.Rect,
world_width: int = 30,
world_height: int = 30,
world_width: int = 20,
world_height: int = 20,
):
self.screen = screen
self.map_rect = map_rect
@ -72,40 +38,24 @@ class MapRenderer:
self._cell_width = map_rect.width / world_width
self._cell_height = map_rect.height / world_height
# Animation state
self.animation_tick = 0
# Pre-generate terrain
# Pre-generate some terrain variation
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 terrain variation using noise-like pattern."""
random.seed(42) # Consistent terrain
"""Generate simple terrain variation (0 = light, 1 = dark, 2 = water)."""
import random
terrain = []
for y in range(self.world_height):
row = []
for x in range(self.world_width):
# 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:
# Simple pattern: mostly grass with occasional water spots
if random.random() < 0.05:
row.append(2) # Water spot
elif (x + y) % 3 == 0:
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:
@ -116,7 +66,6 @@ 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)."""
@ -128,212 +77,70 @@ class MapRenderer:
"""Get the size of a single cell."""
return int(self._cell_width), int(self._cell_height)
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))
def draw(self, state: "SimulationState") -> None:
"""Draw the map background."""
is_night = state.time_of_day == "night"
# Fill background
bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG
surface.fill(bg_color)
pygame.draw.rect(self.screen, bg_color, self.map_rect)
# Draw terrain cells
for y in range(self.world_height):
for x in range(self.world_width):
cell_rect = pygame.Rect(
x * self._cell_width,
y * self._cell_height,
self._cell_width + 1,
self.map_rect.left + x * self._cell_width,
self.map_rect.top + y * self._cell_height,
self._cell_width + 1, # +1 to avoid gaps
self._cell_height + 1,
)
terrain_type = self._terrain_cache[y][x]
if is_night:
# Darker colors at night
if terrain_type == 2:
color = (15, 25, 40)
color = (60, 80, 110)
elif terrain_type == 1:
color = (18, 25, 22)
elif terrain_type == 3:
color = (22, 30, 26)
color = (35, 40, 55)
else:
color = (20, 28, 24)
color = (45, 50, 65)
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(surface, color, cell_rect)
pygame.draw.rect(self.screen, color, cell_rect)
# Draw subtle grid
# Draw grid lines
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 = x * self._cell_width
start_x = self.map_rect.left + x * self._cell_width
pygame.draw.line(
surface,
self.screen,
grid_color,
(start_x, 0),
(start_x, self.map_rect.height),
(start_x, self.map_rect.top),
(start_x, self.map_rect.bottom),
1,
)
# Horizontal lines
for y in range(self.world_height + 1):
start_y = y * self._cell_height
start_y = self.map_rect.top + y * self._cell_height
pygame.draw.line(
surface,
self.screen,
grid_color,
(0, start_y),
(self.map_rect.width, start_y),
(self.map_rect.left, start_y),
(self.map_rect.right, start_y),
1,
)
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)
# Draw border
border_color = (80, 90, 70) if not is_night else (80, 85, 100)
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,7 +1,4 @@
"""Settings UI renderer with sliders for the Village Simulation.
Includes settings for economy, religion, diplomacy, and oil.
"""
"""Settings UI renderer with sliders for the Village Simulation."""
import pygame
from dataclasses import dataclass
@ -10,132 +7,76 @@ from typing import Optional, Callable, Any
class Colors:
"""Color palette for settings UI."""
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)
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)
SLIDER_HANDLE = (220, 220, 230)
BUTTON_BG = (50, 90, 150)
BUTTON_HOVER = (70, 110, 170)
BUTTON_BG = (60, 100, 160)
BUTTON_HOVER = (80, 120, 180)
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
key: str # Dot-separated path like "agent_stats.max_energy"
min_val: float
max_val: float
step: float = 1.0
is_int: bool = True
description: str = ""
section: str = "General"
# Organized slider configs by section
# Define all configurable parameters with sliders
SLIDER_CONFIGS = [
# ═══════════════════════════════════════════════════════════════
# 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"),
# 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"),
# ═══════════════════════════════════════════════════════════════
# 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"),
# 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"),
# ═══════════════════════════════════════════════════════════════
# 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"),
# 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"),
# ═══════════════════════════════════════════════════════════════
# 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"),
# 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"),
# ═══════════════════════════════════════════════════════════════
# 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"),
# 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"),
# ═══════════════════════════════════════════════════════════════
# 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"),
# Simulation Section
SliderConfig("Auto Step (s)", "auto_step_interval", 0.2, 3.0, 0.2, False, "Seconds between auto steps"),
]
# 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."""
@ -156,17 +97,17 @@ class Slider:
self.hovered = False
def set_value(self, value: float) -> None:
"""Set slider value."""
"""Set the 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 current value."""
"""Get the 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 events."""
"""Handle input events. Returns True if value changed."""
if event.type == pygame.MOUSEBUTTONDOWN:
if self._slider_area().collidepoint(event.pos):
self.dragging = True
@ -183,23 +124,27 @@ class Slider:
return False
def _slider_area(self) -> pygame.Rect:
"""Get slider track area."""
"""Get the actual slider track area."""
return pygame.Rect(
self.rect.x + 130,
self.rect.y + 12,
self.rect.width - 200,
16,
self.rect.x + 120, # Leave space for label
self.rect.y + 15,
self.rect.width - 180, # Leave space for value display
20,
)
def _update_from_mouse(self, mouse_x: int) -> bool:
"""Update value from mouse."""
"""Update value based on mouse position."""
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
@ -207,44 +152,45 @@ class Slider:
self.set_value(new_value)
return abs(old_value - self.value) > 0.001
def draw(self, screen: pygame.Surface, section_color: tuple) -> None:
def draw(self, screen: pygame.Surface) -> None:
"""Draw the slider."""
# Hover highlight
# Background
if self.hovered:
pygame.draw.rect(screen, (35, 40, 50), self.rect, border_radius=4)
pygame.draw.rect(screen, (45, 50, 60), self.rect)
# Label
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
screen.blit(label, (self.rect.x + 8, self.rect.y + 6))
screen.blit(label, (self.rect.x + 5, self.rect.y + 5))
# Slider track
slider_area = self._slider_area()
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=4)
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=3)
# 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, section_color, fill_rect, border_radius=4)
pygame.draw.rect(screen, Colors.SLIDER_FILL, fill_rect, border_radius=3)
# Handle
handle_x = slider_area.x + fill_width
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)
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)
# Value display
value_str = str(self.get_value())
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
screen.blit(value_text, (self.rect.right - 55, self.rect.y + 6))
value_x = self.rect.right - 50
screen.blit(value_text, (value_x, self.rect.y + 5))
# 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 + 8, self.rect.y + 24))
screen.blit(desc, (self.rect.x + 5, self.rect.y + 25))
class Button:
"""Button widget."""
"""A simple button widget."""
def __init__(
self,
@ -262,7 +208,7 @@ class Button:
self.hovered = False
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle events."""
"""Handle input events. Returns True if clicked."""
if event.type == pygame.MOUSEMOTION:
self.hovered = self.rect.collidepoint(event.pos)
@ -275,10 +221,10 @@ class Button:
return False
def draw(self, screen: pygame.Surface) -> None:
"""Draw button."""
"""Draw the button."""
color = Colors.BUTTON_HOVER if self.hovered else self.color
pygame.draw.rect(screen, color, self.rect, border_radius=6)
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=6)
pygame.draw.rect(screen, color, self.rect, border_radius=5)
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=5)
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
text_rect = text.get_rect(center=self.rect.center)
@ -286,23 +232,21 @@ class Button:
class SettingsRenderer:
"""Settings panel with organized sections and sliders."""
"""Renders the settings UI panel with sliders."""
def __init__(self, screen: pygame.Surface):
self.screen = screen
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.font = pygame.font.Font(None, 24)
self.small_font = pygame.font.Font(None, 18)
self.title_font = pygame.font.Font(None, 32)
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()
@ -310,44 +254,32 @@ class SettingsRenderer:
self.status_color = Colors.TEXT_SECONDARY
def _create_widgets(self) -> None:
"""Create widgets."""
screen_w, screen_h = self.screen.get_size()
"""Create slider widgets."""
panel_width = 400
slider_height = 45
start_y = 80
# 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
panel_x = (self.screen.get_width() - panel_width) // 2
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)
for i, config in enumerate(SLIDER_CONFIGS):
rect = pygame.Rect(
panel_x + 10,
start_y + i * slider_height,
panel_width - 20,
slider_height,
)
slider = Slider(rect, config, self.font, self.small_font)
self.sliders.append(slider)
self.sliders_by_section[config.section].append(slider)
# Calculate positions for current section
self._layout_current_section()
# 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)
# Buttons at bottom
button_y = panel_y + panel_height - 50
button_width = 120
# Create buttons at the bottom
button_y = self.screen.get_height() - 60
button_width = 100
button_height = 35
button_spacing = 15
buttons_data = [
("Apply & Restart", self._apply_config, Colors.SUCCESS),
@ -355,43 +287,26 @@ class SettingsRenderer:
("Close", self.toggle, Colors.PANEL_BORDER),
]
total_w = len(buttons_data) * button_width + (len(buttons_data) - 1) * button_spacing
start_x = panel_x + (panel_width - total_w) // 2
total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10
start_x = (self.screen.get_width() - total_button_width) // 2
for i, (text, callback, color) in enumerate(buttons_data):
rect = pygame.Rect(
start_x + i * (button_width + button_spacing),
start_x + i * (button_width + 10),
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 visibility."""
"""Toggle settings 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."""
"""Set slider values from config data."""
self.config_data = config_data
for slider in self.sliders:
@ -400,14 +315,16 @@ class SettingsRenderer:
slider.set_value(value)
def get_config(self) -> dict:
"""Get config from sliders."""
"""Get current config from slider values."""
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 nested dict value."""
"""Get a value from nested dict using dot notation."""
parts = key.split(".")
current = data
for part in parts:
@ -418,7 +335,7 @@ class SettingsRenderer:
return current
def _set_nested_value(self, data: dict, key: str, value: Any) -> None:
"""Set nested dict value."""
"""Set a value in nested dict using dot notation."""
parts = key.split(".")
current = data
for part in parts[:-1]:
@ -428,138 +345,104 @@ class SettingsRenderer:
current[parts[-1]] = value
def _apply_config(self) -> None:
"""Apply config callback."""
"""Apply configuration callback (to be set externally)."""
self.status_message = "Config applied - restart to see changes"
self.status_color = Colors.SUCCESS
def _reset_config(self) -> None:
"""Reset config callback."""
"""Reset configuration callback (to be set externally)."""
self.status_message = "Config reset to defaults"
self.status_color = Colors.WARNING
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle events."""
"""Handle input events. Returns True if event was consumed."""
if not self.visible:
return False
# 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
# Handle 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
# Sliders for current section
section = SECTION_ORDER[self.current_section]
for slider in self.sliders_by_section[section]:
adjusted_rect = slider.rect.copy()
# Handle sliders
for slider in self.sliders:
# Adjust slider position for scroll
original_y = slider.rect.y
slider.rect.y -= self.scroll_offset
if slider.handle_event(event):
slider.rect.y = original_y
return True
# Buttons
slider.rect.y = original_y
# Handle buttons
for button in self.buttons:
if button.handle_event(event):
return True
# Consume clicks
# Consume all clicks when settings are visible
if event.type == pygame.MOUSEBUTTONDOWN:
return True
return False
def draw(self) -> None:
"""Draw settings panel."""
"""Draw the settings panel."""
if not self.visible:
return
# Dim background
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 220))
overlay.fill((0, 0, 0, 200))
self.screen.blit(overlay, (0, 0))
# 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)
# 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)
# Title
title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY)
title_rect = title.get_rect(centerx=self.panel_rect.centerx, y=self.panel_rect.y + 8)
title_rect = title.get_rect(centerx=self.screen.get_width() // 2, y=35)
self.screen.blit(title, title_rect)
# Section tabs
self._draw_section_tabs()
# Create clipping region for scrollable area
clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130)
# 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,
)
# 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
# Draw sliders for current section
section = SECTION_ORDER[self.current_section]
section_color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
# 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
for slider in self.sliders_by_section[section]:
if clip_rect.colliderect(slider.rect):
slider.draw(self.screen, section_color)
# Scroll indicator
# Draw scroll indicator
if self.max_scroll > 0:
scroll_ratio = self.scroll_offset / self.max_scroll
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)
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)
# Buttons
# Draw 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.panel_rect.centerx,
y=self.panel_rect.bottom - 80
)
status_rect = status.get_rect(centerx=self.screen.get_width() // 2, y=self.screen.get_height() - 90)
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,591 +1,217 @@
"""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
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from frontend.client import SimulationState
class Colors:
# 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)
# 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)
TEXT_WARNING = (255, 180, 80)
TEXT_DANGER = (255, 100, 100)
TEXT_SUCCESS = (100, 220, 140)
# Day/Night
# Day/Night indicator
DAY_COLOR = (255, 220, 100)
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)
NIGHT_COLOR = (100, 120, 180)
class UIRenderer:
"""Renders UI elements (HUD, panels, text info)."""
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,
):
def __init__(self, screen: pygame.Surface, font: pygame.font.Font):
self.screen = screen
self.font = font
self.top_panel_height = top_panel_height
self.right_panel_width = right_panel_width
self.bottom_panel_height = bottom_panel_height
self.small_font = pygame.font.Font(None, 20)
self.title_font = pygame.font.Font(None, 28)
# 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)
# Panel dimensions
self.top_panel_height = 50
self.right_panel_width = 200
# 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
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)
if title:
# 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)
title_text = self.small_font.render(title, True, Colors.TEXT_SECONDARY)
self.screen.blit(title_text, (rect.x + 8, rect.y + 4))
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 - 1),
(self.screen.get_width(), self.top_panel_height - 1),
self.screen,
Colors.PANEL_BORDER,
(0, self.top_panel_height),
(self.screen.get_width(), self.top_panel_height),
)
# Day/Night indicator with animated glow
# Day/Night and Turn info
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"
# 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))
# 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)
# 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
# Time/day text
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, (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
self.screen.blit(text, (50, 15))
# Mode indicator
mode_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY
mode_text = f"Mode: {state.mode.upper()}"
text = self.medium_font.render(mode_text, True, mode_color)
self.screen.blit(text, (right_x, 10))
text = self.small_font.render(mode_text, True, mode_color)
self.screen.blit(text, (self.screen.get_width() - 120, 8))
# Running status
# Running indicator
if state.is_running:
status_text = "RUNNING"
status_color = Colors.TEXT_SUCCESS
status_text = "RUNNING"
status_color = (100, 200, 100)
else:
status_text = "STOPPED"
status_color = Colors.TEXT_SECONDARY
status_text = "STOPPED"
status_color = Colors.TEXT_DANGER
text = self.medium_font.render(status_text, True, status_color)
self.screen.blit(text, (right_x, 28))
text = self.small_font.render(status_text, True, status_color)
self.screen.blit(text, (self.screen.get_width() - 120, 28))
def draw_right_panel(self, state: "SimulationState") -> None:
"""Draw the right information panel with scrollable content."""
"""Draw the right information panel."""
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, panel_height,
panel_x,
self.top_panel_height,
self.right_panel_width,
self.screen.get_height() - self.top_panel_height,
)
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
pygame.draw.line(
self.screen, Colors.PANEL_BORDER,
self.screen,
Colors.PANEL_BORDER,
(panel_x, self.top_panel_height),
(panel_x, self.screen.get_height() - self.bottom_panel_height),
(panel_x, self.screen.get_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_stats_section(state, content_x, y, content_width)
y += 15
# Statistics section
y = self._draw_statistics_section(state, panel_x + 10, y)
# ═══════════════════════════════════════════════════════════════
# FACTIONS SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_factions_section(state, content_x, y, content_width)
y += 15
# Market section
y = self._draw_market_section(state, panel_x + 10, y + 20)
# ═══════════════════════════════════════════════════════════════
# RELIGION SECTION
# ═══════════════════════════════════════════════════════════════
y = self._draw_religion_section(state, content_x, y, content_width)
y += 15
# Controls help section
self._draw_controls_help(panel_x + 10, self.screen.get_height() - 100)
# ═══════════════════════════════════════════════════════════════
# 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
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
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
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
# 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
# Deaths and money
# Deaths
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"Deaths: {deaths}", True, Colors.TEXT_DANGER)
text = self.small_font.render(f"Total Coins: {total_money}", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 18
text = self.small_font.render(f"💰 {total_money}", True, (255, 215, 0))
self.screen.blit(text, (x + width // 2, y))
y += 16
# Average faith
avg_faith = state.get_avg_faith()
text = self.small_font.render(f"Avg Faith: {avg_faith:.0f}%", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
y += 16
return y
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)
# 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 += 14
y += 16
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)
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 += 12
y += 14
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
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
# 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 += 16
y += 20
# Price summary (show resources with stock)
# Price summary for each resource with available stock
prices = state.market_prices
shown = 0
for resource, data in sorted(prices.items()):
if shown >= 6: # Limit display
break
total_available = data.get("total_available", 0)
if total_available > 0:
for resource, data in prices.items():
if data.get("total_available", 0) > 0:
price = data.get("lowest_price", "?")
# Resource color coding
if "oil" in resource.lower() or "fuel" in resource.lower():
res_color = (180, 160, 100)
elif "meat" in resource.lower():
res_color = (200, 120, 100)
elif "water" in resource.lower():
res_color = (100, 160, 200)
else:
res_color = Colors.TEXT_SECONDARY
res_text = f"{resource[:6]}: {total_available}x @ {price}c"
text = self.small_font.render(res_text, True, res_color)
qty = data.get("total_available", 0)
text = self.small_font.render(
f"{resource}: {qty}x @ {price}c",
True,
Colors.TEXT_SECONDARY,
)
self.screen.blit(text, (x, y))
y += 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
y += 16
return y
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)
def _draw_controls_help(self, x: int, y: int) -> None:
"""Draw controls help at bottom of panel."""
pygame.draw.line(
self.screen, Colors.PANEL_BORDER,
(0, bar_y), (self.screen.get_width(), bar_y),
self.screen,
Colors.PANEL_BORDER,
(x - 5, y - 10),
(self.screen.get_width() - 5, y - 10),
)
# Recent events (religious + diplomatic)
x = 15
y = bar_y + 8
title = self.small_font.render("Controls", True, Colors.TEXT_PRIMARY)
self.screen.blit(title, (x, y))
y += 20
text = self.medium_font.render("Recent Events:", True, Colors.TEXT_SECONDARY)
self.screen.blit(text, (x, y))
x += 120
controls = [
"SPACE - Next Turn",
"R - Reset Simulation",
"M - Toggle Mode",
"S - Settings",
"ESC - Quit",
]
# 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
)
for control in controls:
text = self.small_font.render(control, 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."""
@ -594,52 +220,20 @@ class UIRenderer:
# Semi-transparent overlay
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 200))
overlay.fill((0, 0, 0, 180))
self.screen.blit(overlay, (0, 0))
# 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))
# 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))
self.screen.blit(text, text_rect)
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))
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))
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)

View File

@ -1,83 +0,0 @@
#!/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()

View File

@ -1,738 +0,0 @@
#!/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

@ -1,238 +0,0 @@
#!/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)

279
web_frontend/index.html Normal file
View File

@ -0,0 +1,279 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VillSim - Village Economy Simulation</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<div id="app">
<header id="header">
<div class="header-left">
<h1 class="title">VillSim</h1>
<span class="subtitle">Village Economy Simulation</span>
</div>
<div class="header-center">
<div class="time-display">
<span id="day-display">Day 1</span>
<span class="separator">·</span>
<span id="time-display">☀️ Day</span>
<span class="separator">·</span>
<span id="turn-display">Turn 0</span>
</div>
</div>
<div class="header-right">
<div class="connection-status" id="connection-status">
<span class="status-dot disconnected"></span>
<span class="status-text">Disconnected</span>
</div>
</div>
</header>
<main id="main-content">
<aside id="left-panel" class="panel">
<div class="panel-section">
<h3 class="section-title">Population</h3>
<div class="stat-grid" id="population-stats">
<div class="stat-item">
<span class="stat-value" id="stat-alive">0</span>
<span class="stat-label">Alive</span>
</div>
<div class="stat-item">
<span class="stat-value" id="stat-dead">0</span>
<span class="stat-label">Dead</span>
</div>
</div>
</div>
<div class="panel-section">
<h3 class="section-title">Professions</h3>
<div class="profession-list" id="profession-list">
<!-- Filled by JS -->
</div>
</div>
<div class="panel-section">
<h3 class="section-title">Economy</h3>
<div class="economy-stats" id="economy-stats">
<div class="economy-item">
<span class="economy-label">Money in Circulation</span>
<span class="economy-value" id="stat-money">0</span>
</div>
</div>
</div>
</aside>
<div id="game-container">
<!-- Phaser canvas will be inserted here -->
</div>
<aside id="right-panel" class="panel">
<div class="panel-section agent-section scrollable-section">
<h3 class="section-title">Selected Agent</h3>
<div id="agent-details" class="agent-details">
<p class="no-selection">Click an agent to view details</p>
</div>
</div>
<div class="panel-section">
<h3 class="section-title">Market Prices</h3>
<div class="market-prices" id="market-prices">
<!-- Filled by JS -->
</div>
</div>
<div class="panel-section">
<h3 class="section-title">Activity Log</h3>
<div class="activity-log" id="activity-log">
<!-- Filled by JS -->
</div>
</div>
</aside>
</main>
<footer id="footer">
<div class="controls">
<button id="btn-initialize" class="btn btn-secondary" title="Reset Simulation">
<span class="btn-icon"></span> Reset
</button>
<button id="btn-step" class="btn btn-primary" title="Advance one turn">
<span class="btn-icon"></span> Step
</button>
<button id="btn-auto" class="btn btn-toggle" title="Toggle auto mode">
<span class="btn-icon"></span> Auto
</button>
<button id="btn-stats" class="btn btn-secondary" title="View Statistics">
<span class="btn-icon">📊</span> Stats
</button>
</div>
<div class="speed-control">
<label for="speed-slider">Speed</label>
<input type="range" id="speed-slider" min="50" max="1000" value="150" step="50">
<span id="speed-display">150ms</span>
</div>
</footer>
<!-- Stats Screen (Full View) -->
<div id="stats-screen" class="stats-screen hidden">
<div class="stats-header">
<div class="stats-header-left">
<h2>📊 Simulation Statistics</h2>
<span class="stats-subtitle">Real-time metrics and charts</span>
</div>
<div class="stats-header-center">
<div class="stats-tabs">
<button class="tab-btn active" data-tab="prices">Prices</button>
<button class="tab-btn" data-tab="wealth">Wealth</button>
<button class="tab-btn" data-tab="population">Population</button>
<button class="tab-btn" data-tab="professions">Professions</button>
<button class="tab-btn" data-tab="resources">Resources</button>
<button class="tab-btn" data-tab="market">Market</button>
<button class="tab-btn" data-tab="agents">Agents</button>
</div>
</div>
<div class="stats-header-right">
<button id="btn-close-stats" class="btn btn-secondary">
<span class="btn-icon"></span> Back to Game
</button>
</div>
</div>
<div class="stats-body">
<!-- Prices Tab -->
<div id="tab-prices" class="tab-panel active">
<div class="chart-wrapper">
<canvas id="chart-prices"></canvas>
</div>
</div>
<!-- Wealth Tab -->
<div id="tab-wealth" class="tab-panel">
<div class="chart-grid three-col">
<div class="chart-wrapper">
<canvas id="chart-wealth-dist"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-wealth-prof"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-wealth-time"></canvas>
</div>
</div>
</div>
<!-- Population Tab -->
<div id="tab-population" class="tab-panel">
<div class="chart-wrapper">
<canvas id="chart-population"></canvas>
</div>
</div>
<!-- Professions Tab -->
<div id="tab-professions" class="tab-panel">
<div class="chart-grid two-col">
<div class="chart-wrapper">
<canvas id="chart-prof-pie"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-prof-time"></canvas>
</div>
</div>
</div>
<!-- Resources Tab -->
<div id="tab-resources" class="tab-panel">
<div class="chart-grid four-col">
<div class="chart-wrapper">
<canvas id="chart-res-produced"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-consumed"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-spoiled"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-stock"></canvas>
</div>
</div>
<div class="chart-grid four-col" style="margin-top: 16px;">
<div class="chart-wrapper">
<canvas id="chart-res-cum-produced"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-cum-consumed"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-cum-spoiled"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-res-cum-traded"></canvas>
</div>
</div>
</div>
<!-- Market Tab -->
<div id="tab-market" class="tab-panel">
<div class="chart-grid two-col">
<div class="chart-wrapper">
<canvas id="chart-market-supply"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-market-activity"></canvas>
</div>
</div>
</div>
<!-- Agents Tab -->
<div id="tab-agents" class="tab-panel">
<div class="chart-grid four-col">
<div class="chart-wrapper">
<canvas id="chart-stat-energy"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-stat-hunger"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-stat-thirst"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="chart-stat-heat"></canvas>
</div>
</div>
</div>
</div>
<div class="stats-footer">
<div class="stats-summary-bar">
<div class="summary-item">
<span class="summary-label">Turn</span>
<span class="summary-value" id="stats-turn">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Living</span>
<span class="summary-value highlight" id="stats-living">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Deaths</span>
<span class="summary-value danger" id="stats-deaths">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Total Gold</span>
<span class="summary-value gold" id="stats-gold">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Avg Wealth</span>
<span class="summary-value" id="stats-avg-wealth">0</span>
</div>
<div class="summary-item">
<span class="summary-label">Gini Index</span>
<span class="summary-value" id="stats-gini">0.00</span>
</div>
</div>
</div>
</div>
</div>
<!-- Load game modules -->
<script type="module" src="src/main.js"></script>
</body>
</html>

132
web_frontend/src/api.js Normal file
View File

@ -0,0 +1,132 @@
/**
* VillSim API Client
* Handles all communication with the backend simulation server.
*/
// Auto-detect API base from current page location (same origin)
function getApiBase() {
// When served by the backend, use same origin
if (typeof window !== 'undefined') {
return window.location.origin;
}
// Fallback for development
return 'http://localhost:8000';
}
class SimulationAPI {
constructor() {
this.baseUrl = getApiBase();
this.connected = false;
this.lastState = null;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
this.connected = true;
return await response.json();
} catch (error) {
console.error(`API Error (${endpoint}):`, error.message);
this.connected = false;
throw error;
}
}
// Health check
async checkHealth() {
try {
const data = await this.request('/health');
this.connected = data.status === 'healthy';
return this.connected;
} catch {
this.connected = false;
return false;
}
}
// Get full simulation state
async getState() {
const data = await this.request('/api/state');
this.lastState = data;
return data;
}
// Get all agents
async getAgents() {
return await this.request('/api/agents');
}
// Get specific agent
async getAgent(agentId) {
return await this.request(`/api/agents/${agentId}`);
}
// Get market orders
async getMarketOrders() {
return await this.request('/api/market/orders');
}
// Get market prices
async getMarketPrices() {
return await this.request('/api/market/prices');
}
// Control: Initialize simulation
async initialize(numAgents = 8, worldWidth = 20, worldHeight = 20) {
return await this.request('/api/control/initialize', {
method: 'POST',
body: JSON.stringify({
num_agents: numAgents,
world_width: worldWidth,
world_height: worldHeight,
}),
});
}
// Control: Advance one step
async nextStep() {
return await this.request('/api/control/next_step', {
method: 'POST',
});
}
// Control: Set mode (manual/auto)
async setMode(mode) {
return await this.request('/api/control/mode', {
method: 'POST',
body: JSON.stringify({ mode }),
});
}
// Control: Get status
async getStatus() {
return await this.request('/api/control/status');
}
// Config: Get configuration
async getConfig() {
return await this.request('/api/config');
}
// Logs: Get recent logs
async getLogs(limit = 10) {
return await this.request(`/api/logs?limit=${limit}`);
}
}
// Export singleton instance
export const api = new SimulationAPI();
export default api;

View File

@ -0,0 +1,71 @@
/**
* VillSim Constants
* Shared constants for the Phaser game.
*/
// Profession icons and colors
export const PROFESSIONS = {
hunter: { icon: '🏹', color: 0xc45c5c, name: 'Hunter' },
gatherer: { icon: '🌿', color: 0x6bab5e, name: 'Gatherer' },
woodcutter: { icon: '🪓', color: 0xa67c52, name: 'Woodcutter' },
trader: { icon: '💰', color: 0xd4a84b, name: 'Trader' },
crafter: { icon: '🧵', color: 0x8b6fc0, name: 'Crafter' },
villager: { icon: '👤', color: 0x7a8899, name: 'Villager' },
};
// Resource icons and colors
export const RESOURCES = {
meat: { icon: '🥩', color: 0xc45c5c, name: 'Meat' },
berries: { icon: '🫐', color: 0xa855a8, name: 'Berries' },
water: { icon: '💧', color: 0x5a8cc8, name: 'Water' },
wood: { icon: '🪵', color: 0xa67c52, name: 'Wood' },
hide: { icon: '🦴', color: 0x8b7355, name: 'Hide' },
clothes: { icon: '👕', color: 0x6b6560, name: 'Clothes' },
};
// Action icons
export const ACTIONS = {
hunt: { icon: '🏹', verb: 'hunting' },
gather: { icon: '🌿', verb: 'gathering' },
chop_wood: { icon: '🪓', verb: 'chopping wood' },
get_water: { icon: '💧', verb: 'getting water' },
weave: { icon: '🧵', verb: 'weaving' },
build_fire: { icon: '🔥', verb: 'building fire' },
trade: { icon: '💰', verb: 'trading' },
rest: { icon: '💤', verb: 'resting' },
sleep: { icon: '😴', verb: 'sleeping' },
consume: { icon: '🍽️', verb: 'consuming' },
idle: { icon: '⏳', verb: 'idle' },
};
// Time of day
export const TIME_OF_DAY = {
day: { icon: '☀️', name: 'Day' },
night: { icon: '🌙', name: 'Night' },
};
// World zones (approximate x-positions as percentages)
export const WORLD_ZONES = {
river: { start: 0.0, end: 0.15, color: 0x3a6ea5, name: 'River' },
bushes: { start: 0.15, end: 0.35, color: 0x4a7c59, name: 'Berry Bushes' },
village: { start: 0.35, end: 0.65, color: 0x8b7355, name: 'Village' },
forest: { start: 0.65, end: 1.0, color: 0x2d5016, name: 'Forest' },
};
// Colors for stats
export const STAT_COLORS = {
energy: 0xd4a84b,
hunger: 0xc87f5a,
thirst: 0x5a8cc8,
heat: 0xc45c5c,
};
// Game display settings
export const DISPLAY = {
TILE_SIZE: 32,
AGENT_SIZE: 24,
MIN_ZOOM: 0.5,
MAX_ZOOM: 2.0,
DEFAULT_ZOOM: 1.0,
};

73
web_frontend/src/main.js Normal file
View File

@ -0,0 +1,73 @@
/**
* VillSim - Phaser Web Frontend
* Main entry point
*/
import BootScene from './scenes/BootScene.js';
import GameScene from './scenes/GameScene.js';
// Calculate game dimensions based on container
function getGameDimensions() {
const container = document.getElementById('game-container');
if (!container) {
return { width: 800, height: 600 };
}
const rect = container.getBoundingClientRect();
return {
width: Math.floor(rect.width),
height: Math.floor(rect.height),
};
}
// Phaser game configuration
const { width, height } = getGameDimensions();
const config = {
type: Phaser.AUTO,
parent: 'game-container',
width: width,
height: height,
backgroundColor: '#151921',
scene: [BootScene, GameScene],
scale: {
mode: Phaser.Scale.RESIZE,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
render: {
antialias: true,
pixelArt: false,
roundPixels: true,
},
physics: {
default: 'arcade',
arcade: {
gravity: { y: 0 },
debug: false,
},
},
dom: {
createContainer: true,
},
};
// Initialize game when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
console.log('VillSim Web Frontend starting...');
// Create Phaser game
const game = new Phaser.Game(config);
// Handle window resize
window.addEventListener('resize', () => {
const { width, height } = getGameDimensions();
game.scale.resize(width, height);
});
// Store game reference globally for debugging
window.villsimGame = game;
});
// Export for debugging
export { config };

View File

@ -0,0 +1,141 @@
/**
* BootScene - Initial loading and setup
*/
import { api } from '../api.js';
export default class BootScene extends Phaser.Scene {
constructor() {
super({ key: 'BootScene' });
}
preload() {
// Create loading graphics
const { width, height } = this.cameras.main;
// Background
this.add.rectangle(width / 2, height / 2, width, height, 0x151921);
// Loading text
this.loadingText = this.add.text(width / 2, height / 2 - 40, 'VillSim', {
fontSize: '48px',
fontFamily: 'Crimson Pro, Georgia, serif',
color: '#d4a84b',
}).setOrigin(0.5);
this.statusText = this.add.text(width / 2, height / 2 + 20, 'Connecting to server...', {
fontSize: '18px',
fontFamily: 'Crimson Pro, Georgia, serif',
color: '#a8a095',
}).setOrigin(0.5);
// Progress bar background
const barWidth = 300;
const barHeight = 8;
this.progressBg = this.add.rectangle(
width / 2, height / 2 + 60,
barWidth, barHeight,
0x242b3d
).setOrigin(0.5);
this.progressBar = this.add.rectangle(
width / 2 - barWidth / 2, height / 2 + 60,
0, barHeight,
0xd4a84b
).setOrigin(0, 0.5);
}
async create() {
// Animate progress bar
this.tweens.add({
targets: this.progressBar,
width: 100,
duration: 500,
ease: 'Power2',
});
// Attempt to connect to server
this.statusText.setText('Connecting to server...');
let connected = false;
let retries = 0;
const maxRetries = 10;
while (!connected && retries < maxRetries) {
try {
connected = await api.checkHealth();
if (connected) {
this.statusText.setText('Connected! Loading simulation...');
this.tweens.add({
targets: this.progressBar,
width: 200,
duration: 300,
ease: 'Power2',
});
} else {
retries++;
this.statusText.setText(`Connecting... (attempt ${retries}/${maxRetries})`);
await this.delay(1000);
}
} catch (error) {
retries++;
this.statusText.setText(`Connection failed. Retrying... (${retries}/${maxRetries})`);
await this.delay(1000);
}
}
if (!connected) {
this.statusText.setText('Could not connect to server. Is the backend running?');
this.statusText.setColor('#c45c5c');
// Add retry button
const retryBtn = this.add.text(
this.cameras.main.width / 2,
this.cameras.main.height / 2 + 100,
'[ Click to Retry ]',
{
fontSize: '16px',
fontFamily: 'Crimson Pro, Georgia, serif',
color: '#d4a84b',
}
).setOrigin(0.5).setInteractive({ useHandCursor: true });
retryBtn.on('pointerup', () => {
this.scene.restart();
});
retryBtn.on('pointerover', () => retryBtn.setColor('#e8e4dc'));
retryBtn.on('pointerout', () => retryBtn.setColor('#d4a84b'));
return;
}
// Load initial state
try {
const state = await api.getState();
this.registry.set('simulationState', state);
this.tweens.add({
targets: this.progressBar,
width: 300,
duration: 300,
ease: 'Power2',
onComplete: () => {
this.statusText.setText('Starting simulation...');
this.time.delayedCall(500, () => {
this.scene.start('GameScene');
});
}
});
} catch (error) {
this.statusText.setText('Error loading simulation state');
this.statusText.setColor('#c45c5c');
console.error('Failed to load state:', error);
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
/**
* Scene exports
*/
export { default as BootScene } from './BootScene.js';
export { default as GameScene } from './GameScene.js';

1053
web_frontend/styles.css Normal file

File diff suppressed because it is too large Load Diff