Enhance Village Simulation with religion and diplomacy systems, introducing diverse agent beliefs and faction dynamics. Updated configuration parameters for agent stats, resource decay, and economic interactions. Implemented new actions related to religion and diplomacy, including praying, preaching, and negotiating. Improved UI for displaying religious and diplomatic information, and added tools for testing and optimizing balance in these new systems.
This commit is contained in:
parent
1423fc0dc9
commit
cfd6c87f86
@ -28,10 +28,12 @@ class StatsSchema(BaseModel):
|
||||
hunger: int
|
||||
thirst: int
|
||||
heat: int
|
||||
faith: int = 50
|
||||
max_energy: int
|
||||
max_hunger: int
|
||||
max_thirst: int
|
||||
max_heat: int
|
||||
max_faith: int = 100
|
||||
|
||||
|
||||
class AgentActionSchema(BaseModel):
|
||||
@ -44,6 +46,28 @@ class AgentActionSchema(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ReligionSchema(BaseModel):
|
||||
"""Schema for agent religion data."""
|
||||
religion: str
|
||||
faith: int
|
||||
is_zealot: bool = False
|
||||
times_converted: int = 0
|
||||
converts_made: int = 0
|
||||
description: str = ""
|
||||
|
||||
|
||||
class DiplomacySchema(BaseModel):
|
||||
"""Schema for agent diplomacy data."""
|
||||
faction: str
|
||||
faction_description: str = ""
|
||||
faction_color: str = "#808080"
|
||||
diplomacy_skill: float = 0.5
|
||||
aggression: float = 0.3
|
||||
negotiations_conducted: int = 0
|
||||
wars_declared: int = 0
|
||||
peace_treaties_made: int = 0
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
"""Schema for agent data."""
|
||||
id: str
|
||||
@ -58,6 +82,9 @@ class AgentResponse(BaseModel):
|
||||
can_act: bool
|
||||
current_action: AgentActionSchema
|
||||
last_action_result: str
|
||||
# Religion and diplomacy
|
||||
religion: Optional[ReligionSchema] = None
|
||||
diplomacy: Optional[DiplomacySchema] = None
|
||||
|
||||
|
||||
# ============== Market Schemas ==============
|
||||
|
||||
@ -12,41 +12,49 @@ class AgentStatsConfig:
|
||||
# Maximum values
|
||||
max_energy: int = 50
|
||||
max_hunger: int = 100
|
||||
max_thirst: int = 100 # Increased from 50 to give more buffer
|
||||
max_thirst: int = 100
|
||||
max_heat: int = 100
|
||||
max_faith: int = 100 # NEW: Religious faith level
|
||||
|
||||
# Starting values
|
||||
start_energy: int = 50
|
||||
start_hunger: int = 80
|
||||
start_thirst: int = 80 # Increased from 40 to start with more buffer
|
||||
start_thirst: int = 80
|
||||
start_heat: int = 100
|
||||
start_faith: int = 50 # NEW: Start with moderate faith
|
||||
|
||||
# Decay rates per turn
|
||||
energy_decay: int = 2
|
||||
hunger_decay: int = 2
|
||||
thirst_decay: int = 2 # Reduced from 3 to match hunger decay rate
|
||||
thirst_decay: int = 2
|
||||
heat_decay: int = 2
|
||||
faith_decay: int = 1 # NEW: Faith decays slowly without religious activity
|
||||
|
||||
# Thresholds
|
||||
critical_threshold: float = 0.25 # 25% triggers survival mode
|
||||
low_energy_threshold: int = 15 # Minimum energy to work
|
||||
critical_threshold: float = 0.25
|
||||
low_energy_threshold: int = 15
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceConfig:
|
||||
"""Configuration for resource properties."""
|
||||
# Decay rates (turns until spoilage, 0 = infinite)
|
||||
meat_decay: int = 8 # Increased from 5 to give more time to use
|
||||
meat_decay: int = 8
|
||||
berries_decay: int = 25
|
||||
clothes_decay: int = 50
|
||||
oil_decay: int = 0 # NEW: Oil doesn't decay
|
||||
fuel_decay: int = 0 # NEW: Refined fuel doesn't decay
|
||||
|
||||
# Resource effects
|
||||
meat_hunger: int = 30
|
||||
meat_energy: int = 5
|
||||
berries_hunger: int = 8 # Increased from 5
|
||||
berries_thirst: int = 3 # Increased from 2
|
||||
water_thirst: int = 50 # Increased from 40 for better thirst recovery
|
||||
fire_heat: int = 15 # Increased from 10
|
||||
berries_hunger: int = 8
|
||||
berries_thirst: int = 3
|
||||
water_thirst: int = 50
|
||||
fire_heat: int = 15
|
||||
fuel_heat: int = 35 # NEW: Fuel provides more heat than wood
|
||||
oil_energy: int = 0 # NEW: Raw oil has no direct use
|
||||
fuel_energy: int = 8 # NEW: Refined fuel provides energy
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -63,9 +71,23 @@ class ActionConfig:
|
||||
build_fire_energy: int = -5
|
||||
trade_energy: int = -1
|
||||
|
||||
# NEW: Oil industry actions
|
||||
drill_oil_energy: int = -10
|
||||
refine_energy: int = -8
|
||||
|
||||
# NEW: Religious actions
|
||||
pray_energy: int = -2
|
||||
preach_energy: int = -4
|
||||
|
||||
# NEW: Diplomatic actions
|
||||
negotiate_energy: int = -3
|
||||
declare_war_energy: int = -5
|
||||
make_peace_energy: int = -3
|
||||
|
||||
# Success chances (0.0 to 1.0)
|
||||
hunt_success: float = 0.7
|
||||
chop_wood_success: float = 0.9
|
||||
drill_oil_success: float = 0.6 # NEW: Harder to extract oil
|
||||
|
||||
# Output quantities
|
||||
hunt_meat_min: int = 1
|
||||
@ -76,6 +98,15 @@ class ActionConfig:
|
||||
gather_max: int = 5
|
||||
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
|
||||
@ -90,42 +121,59 @@ class WorldConfig:
|
||||
# Agent configuration
|
||||
inventory_slots: int = 10
|
||||
starting_money: int = 100
|
||||
|
||||
# NEW: World features
|
||||
oil_fields_count: int = 3 # Number of oil field locations
|
||||
temple_count: int = 2 # Number of temple/religious locations
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketConfig:
|
||||
"""Configuration for market behavior."""
|
||||
turns_before_discount: int = 3
|
||||
discount_rate: float = 0.15 # 15% discount after waiting
|
||||
base_price_multiplier: float = 1.2 # Markup over production cost
|
||||
discount_rate: float = 0.15
|
||||
base_price_multiplier: float = 1.2
|
||||
|
||||
|
||||
@dataclass
|
||||
class EconomyConfig:
|
||||
"""Configuration for economic behavior and agent trading.
|
||||
|
||||
These values control how agents perceive the value of money and trading.
|
||||
Higher values make agents more trade-oriented.
|
||||
"""
|
||||
# How much agents value money vs energy
|
||||
# Higher = agents see money as more valuable (trade more)
|
||||
energy_to_money_ratio: float = 1.5 # 1 energy ≈ 1.5 coins
|
||||
|
||||
# How strongly agents desire wealth (0-1)
|
||||
# Higher = agents will prioritize building wealth
|
||||
"""Configuration for economic behavior and agent trading."""
|
||||
energy_to_money_ratio: float = 1.5
|
||||
wealth_desire: float = 0.3
|
||||
|
||||
# Buy efficiency threshold (0-1)
|
||||
# If market price < (threshold * fair_value), buy instead of gather
|
||||
# 0.7 means: buy if price is 70% or less of the fair value
|
||||
buy_efficiency_threshold: float = 0.7
|
||||
|
||||
# Minimum wealth target - agents want at least this much money
|
||||
min_wealth_target: int = 50
|
||||
max_price_markup: float = 2.0
|
||||
min_price_discount: float = 0.5
|
||||
|
||||
# Price adjustment limits
|
||||
max_price_markup: float = 2.0 # Maximum price = 2x base value
|
||||
min_price_discount: float = 0.5 # Minimum price = 50% of base value
|
||||
# NEW: Oil economy
|
||||
oil_base_price: int = 25 # Oil is valuable
|
||||
fuel_base_price: int = 40 # Refined fuel is more valuable
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReligionConfig:
|
||||
"""Configuration for religion system."""
|
||||
num_religions: int = 3 # Number of different religions
|
||||
conversion_resistance: float = 0.5 # How hard to convert agents
|
||||
zealot_threshold: float = 0.80 # Faith level for zealot behavior
|
||||
faith_trade_bonus: float = 0.10 # Bonus when trading with same religion
|
||||
same_religion_bonus: float = 0.15 # General bonus with same religion
|
||||
different_religion_penalty: float = 0.10 # Penalty with different religion
|
||||
holy_war_threshold: float = 0.90 # Faith level to trigger religious conflict
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiplomacyConfig:
|
||||
"""Configuration for diplomacy and faction system."""
|
||||
num_factions: int = 4 # Number of factions
|
||||
starting_relations: int = 50 # Neutral starting relations (0-100)
|
||||
alliance_threshold: int = 75 # Relations needed for alliance
|
||||
war_threshold: int = 25 # Relations below this = hostile
|
||||
relation_decay: int = 1 # Relations decay towards neutral
|
||||
trade_relation_boost: int = 2 # Trading improves relations
|
||||
war_damage_multiplier: float = 1.5 # Extra damage during war
|
||||
peace_treaty_duration: int = 20 # Turns peace treaty lasts
|
||||
war_exhaustion_rate: int = 2 # How fast war exhaustion builds
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -137,9 +185,11 @@ class SimulationConfig:
|
||||
world: WorldConfig = field(default_factory=WorldConfig)
|
||||
market: MarketConfig = field(default_factory=MarketConfig)
|
||||
economy: EconomyConfig = field(default_factory=EconomyConfig)
|
||||
religion: ReligionConfig = field(default_factory=ReligionConfig) # NEW
|
||||
diplomacy: DiplomacyConfig = field(default_factory=DiplomacyConfig) # NEW
|
||||
|
||||
# Simulation control
|
||||
auto_step_interval: float = 1.0 # Seconds between auto steps
|
||||
auto_step_interval: float = 1.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
@ -150,6 +200,8 @@ class SimulationConfig:
|
||||
"world": asdict(self.world),
|
||||
"market": asdict(self.market),
|
||||
"economy": asdict(self.economy),
|
||||
"religion": asdict(self.religion),
|
||||
"diplomacy": asdict(self.diplomacy),
|
||||
"auto_step_interval": self.auto_step_interval,
|
||||
}
|
||||
|
||||
@ -163,6 +215,8 @@ class SimulationConfig:
|
||||
world=WorldConfig(**data.get("world", {})),
|
||||
market=MarketConfig(**data.get("market", {})),
|
||||
economy=EconomyConfig(**data.get("economy", {})),
|
||||
religion=ReligionConfig(**data.get("religion", {})),
|
||||
diplomacy=DiplomacyConfig(**data.get("diplomacy", {})),
|
||||
auto_step_interval=data.get("auto_step_interval", 1.0),
|
||||
)
|
||||
|
||||
@ -179,7 +233,7 @@ class SimulationConfig:
|
||||
data = json.load(f)
|
||||
return cls.from_dict(data)
|
||||
except FileNotFoundError:
|
||||
return cls() # Return defaults if file not found
|
||||
return cls()
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
@ -187,10 +241,7 @@ _config: Optional[SimulationConfig] = None
|
||||
|
||||
|
||||
def get_config() -> SimulationConfig:
|
||||
"""Get the global configuration instance.
|
||||
|
||||
Loads from config.json if not already loaded.
|
||||
"""
|
||||
"""Get the global configuration instance."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = load_config()
|
||||
@ -202,8 +253,6 @@ def load_config(path: str = "config.json") -> SimulationConfig:
|
||||
try:
|
||||
config_path = Path(path)
|
||||
if not config_path.is_absolute():
|
||||
# Try relative to workspace root (villsim/)
|
||||
# __file__ is backend/config.py, so .parent.parent is villsim/
|
||||
workspace_root = Path(__file__).parent.parent
|
||||
config_path = workspace_root / path
|
||||
|
||||
@ -214,7 +263,7 @@ def load_config(path: str = "config.json") -> SimulationConfig:
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"Warning: Could not load config from {path}: {e}")
|
||||
|
||||
return SimulationConfig() # Return defaults if file not found
|
||||
return SimulationConfig()
|
||||
|
||||
|
||||
def set_config(config: SimulationConfig) -> None:
|
||||
@ -252,4 +301,3 @@ def _reset_all_caches() -> None:
|
||||
reset_resource_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,10 @@
|
||||
"""Game Engine for the Village Simulation."""
|
||||
"""Game Engine for the Village Simulation.
|
||||
|
||||
Now includes support for:
|
||||
- Oil industry (drill_oil, refine, burn_fuel)
|
||||
- Religion (pray, preach)
|
||||
- Diplomacy (negotiate, declare_war, make_peace)
|
||||
"""
|
||||
|
||||
import random
|
||||
import threading
|
||||
@ -9,8 +15,12 @@ from typing import Optional
|
||||
|
||||
from backend.domain.agent import Agent
|
||||
from backend.domain.action import ActionType, ActionResult, ACTION_CONFIG
|
||||
from backend.domain.resources import Resource, ResourceType
|
||||
from backend.domain.resources import Resource, ResourceType, get_fire_heat, get_fuel_heat
|
||||
from backend.domain.personality import get_action_skill_modifier
|
||||
from backend.domain.religion import get_religion_action_bonus
|
||||
from backend.domain.diplomacy import (
|
||||
FactionType, get_faction_relations, reset_faction_relations
|
||||
)
|
||||
from backend.core.world import World, WorldConfig, TimeOfDay
|
||||
from backend.core.market import OrderBook
|
||||
from backend.core.ai import get_ai_decision, AIDecision
|
||||
@ -20,8 +30,8 @@ from backend.config import get_config
|
||||
|
||||
class SimulationMode(Enum):
|
||||
"""Simulation run mode."""
|
||||
MANUAL = "manual" # Wait for explicit next_step call
|
||||
AUTO = "auto" # Run automatically with timer
|
||||
MANUAL = "manual"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -31,6 +41,8 @@ class TurnLog:
|
||||
agent_actions: list[dict] = field(default_factory=list)
|
||||
deaths: list[str] = field(default_factory=list)
|
||||
trades: list[dict] = field(default_factory=list)
|
||||
religious_events: list[dict] = field(default_factory=list) # NEW
|
||||
diplomatic_events: list[dict] = field(default_factory=list) # NEW
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@ -38,6 +50,8 @@ class TurnLog:
|
||||
"agent_actions": self.agent_actions,
|
||||
"deaths": self.deaths,
|
||||
"trades": self.trades,
|
||||
"religious_events": self.religious_events,
|
||||
"diplomatic_events": self.diplomatic_events,
|
||||
}
|
||||
|
||||
|
||||
@ -60,7 +74,6 @@ class GameEngine:
|
||||
self.market = OrderBook()
|
||||
self.mode = SimulationMode.MANUAL
|
||||
self.is_running = False
|
||||
# Load auto_step_interval from config
|
||||
self.auto_step_interval = get_config().auto_step_interval
|
||||
self._auto_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
@ -70,9 +83,11 @@ class GameEngine:
|
||||
|
||||
def reset(self, config: Optional[WorldConfig] = None) -> None:
|
||||
"""Reset the simulation to initial state."""
|
||||
# Stop auto mode if running
|
||||
self._stop_auto_mode()
|
||||
|
||||
# Reset faction relations
|
||||
reset_faction_relations()
|
||||
|
||||
if config:
|
||||
self.world = World(config=config)
|
||||
else:
|
||||
@ -80,7 +95,6 @@ class GameEngine:
|
||||
self.market = OrderBook()
|
||||
self.turn_logs = []
|
||||
|
||||
# Reset and start new logging session
|
||||
self.logger = reset_simulation_logger()
|
||||
sim_config = get_config()
|
||||
self.logger.start_session(sim_config.to_dict())
|
||||
@ -89,18 +103,15 @@ class GameEngine:
|
||||
self.is_running = True
|
||||
|
||||
def initialize(self, num_agents: Optional[int] = None) -> None:
|
||||
"""Initialize the simulation with agents.
|
||||
"""Initialize the simulation with agents."""
|
||||
# Reset faction relations
|
||||
reset_faction_relations()
|
||||
|
||||
Args:
|
||||
num_agents: Number of agents to spawn. If None, uses config.json value.
|
||||
"""
|
||||
if num_agents is not None:
|
||||
self.world.config.initial_agents = num_agents
|
||||
# Otherwise use the value already loaded from config.json
|
||||
|
||||
self.world.initialize()
|
||||
|
||||
# Start logging session
|
||||
self.logger = reset_simulation_logger()
|
||||
sim_config = get_config()
|
||||
self.logger.start_session(sim_config.to_dict())
|
||||
@ -115,7 +126,6 @@ class GameEngine:
|
||||
turn_log = TurnLog(turn=self.world.current_turn + 1)
|
||||
current_turn = self.world.current_turn + 1
|
||||
|
||||
# Start logging this turn
|
||||
self.logger.start_turn(
|
||||
turn=current_turn,
|
||||
day=self.world.current_day,
|
||||
@ -123,16 +133,14 @@ class GameEngine:
|
||||
time_of_day=self.world.time_of_day.value,
|
||||
)
|
||||
|
||||
# Log market state before
|
||||
market_orders_before = [o.to_dict() for o in self.market.get_active_orders()]
|
||||
|
||||
# 0. Remove corpses from previous turn (agents who died last turn)
|
||||
# Remove old corpses
|
||||
self._remove_old_corpses(current_turn)
|
||||
|
||||
# 1. Collect AI decisions for all living agents (not corpses)
|
||||
# Collect AI decisions
|
||||
decisions: list[tuple[Agent, AIDecision]] = []
|
||||
for agent in self.world.get_living_agents():
|
||||
# Log agent state before
|
||||
self.logger.log_agent_before(
|
||||
agent_id=agent.id,
|
||||
agent_name=agent.name,
|
||||
@ -144,27 +152,24 @@ class GameEngine:
|
||||
)
|
||||
|
||||
if self.world.is_night():
|
||||
# Force sleep at night
|
||||
decision = AIDecision(
|
||||
action=ActionType.SLEEP,
|
||||
reason="Night time: sleeping",
|
||||
)
|
||||
else:
|
||||
# Pass time info so AI can prepare for night
|
||||
decision = get_ai_decision(
|
||||
agent,
|
||||
self.market,
|
||||
step_in_day=self.world.step_in_day,
|
||||
day_steps=self.world.config.day_steps,
|
||||
current_turn=current_turn,
|
||||
world=self.world,
|
||||
)
|
||||
|
||||
decisions.append((agent, decision))
|
||||
|
||||
# Log decision
|
||||
self.logger.log_agent_decision(agent.id, decision.to_dict())
|
||||
|
||||
# 2. Calculate movement targets and move agents
|
||||
# Calculate movement
|
||||
for agent, decision in decisions:
|
||||
action_name = decision.action.value
|
||||
agent.set_action(
|
||||
@ -173,14 +178,14 @@ class GameEngine:
|
||||
world_height=self.world.config.height,
|
||||
message=decision.reason,
|
||||
target_resource=decision.target_resource.value if decision.target_resource else None,
|
||||
target_agent=decision.target_agent_id,
|
||||
)
|
||||
agent.update_movement()
|
||||
|
||||
# 3. Execute all actions and update action indicators with results
|
||||
# Execute actions
|
||||
for agent, decision in decisions:
|
||||
result = self._execute_action(agent, decision)
|
||||
result = self._execute_action(agent, decision, turn_log)
|
||||
|
||||
# Complete agent action with result - this updates the indicator to show what was done
|
||||
if result:
|
||||
agent.complete_action(result.success, result.message)
|
||||
|
||||
@ -191,7 +196,6 @@ class GameEngine:
|
||||
"result": result.to_dict() if result else None,
|
||||
})
|
||||
|
||||
# Log agent state after action
|
||||
self.logger.log_agent_after(
|
||||
agent_id=agent.id,
|
||||
stats=agent.stats.to_dict(),
|
||||
@ -201,40 +205,35 @@ class GameEngine:
|
||||
action_result=result.to_dict() if result else {},
|
||||
)
|
||||
|
||||
# 4. Resolve pending market orders (price updates)
|
||||
# Update market prices
|
||||
self.market.update_prices(current_turn)
|
||||
|
||||
# Log market state after
|
||||
market_orders_after = [o.to_dict() for o in self.market.get_active_orders()]
|
||||
self.logger.log_market_state(market_orders_before, market_orders_after)
|
||||
|
||||
# 5. Apply passive decay to all living agents
|
||||
# Apply passive decay
|
||||
for agent in self.world.get_living_agents():
|
||||
agent.apply_passive_decay()
|
||||
|
||||
# 6. Decay resources in inventories
|
||||
# Decay resources
|
||||
for agent in self.world.get_living_agents():
|
||||
expired = agent.decay_inventory(current_turn)
|
||||
|
||||
# 7. Mark newly dead agents as corpses (don't remove yet for visualization)
|
||||
# Mark dead agents
|
||||
newly_dead = self._mark_dead_agents(current_turn)
|
||||
for dead_agent in newly_dead:
|
||||
cause = dead_agent.death_reason
|
||||
self.logger.log_death(dead_agent.name, cause)
|
||||
# Cancel their market orders immediately
|
||||
self.market.cancel_seller_orders(dead_agent.id)
|
||||
turn_log.deaths = [a.name for a in newly_dead]
|
||||
|
||||
# Log statistics
|
||||
self.logger.log_statistics(self.world.get_statistics())
|
||||
|
||||
# End turn logging
|
||||
self.logger.end_turn()
|
||||
|
||||
# 8. Advance time
|
||||
# Advance time
|
||||
self.world.advance_time()
|
||||
|
||||
# 9. Check win/lose conditions (count only truly living agents, not corpses)
|
||||
# Check end conditions
|
||||
if len(self.world.get_living_agents()) == 0:
|
||||
self.is_running = False
|
||||
self.logger.close()
|
||||
@ -243,14 +242,12 @@ class GameEngine:
|
||||
return turn_log
|
||||
|
||||
def _mark_dead_agents(self, current_turn: int) -> list[Agent]:
|
||||
"""Mark agents who just died as corpses. Returns list of newly dead agents."""
|
||||
"""Mark agents who just died as corpses."""
|
||||
newly_dead = []
|
||||
for agent in self.world.agents:
|
||||
if not agent.is_alive() and not agent.is_corpse():
|
||||
# Agent just died this turn
|
||||
cause = agent.stats.get_critical_stat() or "unknown"
|
||||
agent.mark_dead(current_turn, cause)
|
||||
# Clear their action to show death state
|
||||
agent.current_action.action_type = "dead"
|
||||
agent.current_action.message = f"Died: {cause}"
|
||||
newly_dead.append(agent)
|
||||
@ -261,7 +258,6 @@ class GameEngine:
|
||||
to_remove = []
|
||||
for agent in self.world.agents:
|
||||
if agent.is_corpse() and agent.death_turn < current_turn:
|
||||
# Corpse has been visible for one turn, remove it
|
||||
to_remove.append(agent)
|
||||
|
||||
for agent in to_remove:
|
||||
@ -270,12 +266,12 @@ class GameEngine:
|
||||
|
||||
return to_remove
|
||||
|
||||
def _execute_action(self, agent: Agent, decision: AIDecision) -> Optional[ActionResult]:
|
||||
def _execute_action(self, agent: Agent, decision: AIDecision, turn_log: TurnLog) -> Optional[ActionResult]:
|
||||
"""Execute an action for an agent."""
|
||||
action = decision.action
|
||||
config = ACTION_CONFIG[action]
|
||||
|
||||
# Handle different action types
|
||||
# Basic actions
|
||||
if action == ActionType.SLEEP:
|
||||
agent.restore_energy(config.energy_cost)
|
||||
return ActionResult(
|
||||
@ -309,8 +305,6 @@ class GameEngine:
|
||||
agent.remove_from_inventory(ResourceType.WOOD, 1)
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=action, success=False, message="Not enough energy")
|
||||
# Fire heat from config
|
||||
from backend.domain.resources import get_fire_heat
|
||||
fire_heat = get_fire_heat()
|
||||
agent.apply_heat(fire_heat)
|
||||
return ActionResult(
|
||||
@ -322,25 +316,56 @@ class GameEngine:
|
||||
)
|
||||
return ActionResult(action_type=action, success=False, message="No wood for fire")
|
||||
|
||||
elif action == ActionType.BURN_FUEL:
|
||||
if agent.has_resource(ResourceType.FUEL):
|
||||
agent.remove_from_inventory(ResourceType.FUEL, 1)
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=action, success=False, message="Not enough energy")
|
||||
fuel_heat = get_fuel_heat()
|
||||
agent.apply_heat(fuel_heat)
|
||||
# Fuel also provides energy
|
||||
from backend.config import get_config
|
||||
fuel_energy = get_config().resources.fuel_energy
|
||||
agent.restore_energy(fuel_energy)
|
||||
return ActionResult(
|
||||
action_type=action,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
heat_gained=fuel_heat,
|
||||
message=f"Burned fuel (+{fuel_heat} heat, +{fuel_energy} energy)",
|
||||
)
|
||||
return ActionResult(action_type=action, success=False, message="No fuel to burn")
|
||||
|
||||
elif action == ActionType.TRADE:
|
||||
return self._execute_trade(agent, decision)
|
||||
|
||||
# Religious actions
|
||||
elif action == ActionType.PRAY:
|
||||
return self._execute_pray(agent, config, turn_log)
|
||||
|
||||
elif action == ActionType.PREACH:
|
||||
return self._execute_preach(agent, config, turn_log)
|
||||
|
||||
# Diplomatic actions
|
||||
elif action == ActionType.NEGOTIATE:
|
||||
return self._execute_negotiate(agent, decision, config, turn_log)
|
||||
|
||||
elif action == ActionType.DECLARE_WAR:
|
||||
return self._execute_declare_war(agent, decision, config, turn_log)
|
||||
|
||||
elif action == ActionType.MAKE_PEACE:
|
||||
return self._execute_make_peace(agent, decision, config, turn_log)
|
||||
|
||||
# Production actions
|
||||
elif action in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||
ActionType.GET_WATER, ActionType.WEAVE]:
|
||||
ActionType.GET_WATER, ActionType.WEAVE, ActionType.DRILL_OIL,
|
||||
ActionType.REFINE]:
|
||||
return self._execute_work(agent, action, config)
|
||||
|
||||
return ActionResult(action_type=action, success=False, message="Unknown action")
|
||||
|
||||
def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult:
|
||||
"""Execute a work action (hunting, gathering, etc.).
|
||||
|
||||
Skills now affect outcomes:
|
||||
- Hunting skill affects hunt success rate
|
||||
- Gathering skill affects gather output
|
||||
- Woodcutting skill affects wood output
|
||||
- Skills improve with use
|
||||
"""
|
||||
# Check energy
|
||||
"""Execute a work action (hunting, gathering, drilling, etc.)."""
|
||||
energy_cost = abs(config.energy_cost)
|
||||
if not agent.spend_energy(energy_cost):
|
||||
return ActionResult(
|
||||
@ -349,10 +374,9 @@ class GameEngine:
|
||||
message="Not enough energy",
|
||||
)
|
||||
|
||||
# Check required materials
|
||||
if config.requires_resource:
|
||||
if not agent.has_resource(config.requires_resource, config.requires_quantity):
|
||||
agent.restore_energy(energy_cost) # Refund energy
|
||||
agent.restore_energy(energy_cost)
|
||||
return ActionResult(
|
||||
action_type=action,
|
||||
success=False,
|
||||
@ -360,19 +384,22 @@ class GameEngine:
|
||||
)
|
||||
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
|
||||
|
||||
# Get relevant skill for this action
|
||||
# Get skill modifier
|
||||
skill_name = self._get_skill_for_action(action)
|
||||
skill_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0
|
||||
skill_modifier = get_action_skill_modifier(skill_value)
|
||||
|
||||
# Check success chance (modified by skill)
|
||||
# Higher skill = higher effective success chance
|
||||
effective_success_chance = min(0.98, config.success_chance * skill_modifier)
|
||||
# Get religion bonus
|
||||
religion_bonus = get_religion_action_bonus(agent.religion.religion, action.value)
|
||||
|
||||
# Combined modifier
|
||||
total_modifier = skill_modifier * religion_bonus
|
||||
|
||||
effective_success_chance = min(0.98, config.success_chance * total_modifier)
|
||||
if random.random() > effective_success_chance:
|
||||
# Record action attempt (skill still improves on failure, just less)
|
||||
agent.record_action(action.value)
|
||||
if skill_name:
|
||||
agent.skills.improve(skill_name, 0.005) # Small improvement on failure
|
||||
agent.skills.improve(skill_name, 0.005)
|
||||
return ActionResult(
|
||||
action_type=action,
|
||||
success=False,
|
||||
@ -380,13 +407,11 @@ class GameEngine:
|
||||
message="Action failed",
|
||||
)
|
||||
|
||||
# Generate output (modified by skill for quantity)
|
||||
resources_gained = []
|
||||
|
||||
if config.output_resource:
|
||||
# Skill affects output quantity
|
||||
base_quantity = random.randint(config.min_output, config.max_output)
|
||||
quantity = max(config.min_output, int(base_quantity * skill_modifier))
|
||||
quantity = max(config.min_output, int(base_quantity * total_modifier))
|
||||
|
||||
if quantity > 0:
|
||||
resource = Resource(
|
||||
@ -402,10 +427,9 @@ class GameEngine:
|
||||
created_turn=self.world.current_turn,
|
||||
))
|
||||
|
||||
# Secondary output (e.g., hide from hunting) - also affected by skill
|
||||
if config.secondary_output:
|
||||
base_quantity = random.randint(config.secondary_min, config.secondary_max)
|
||||
quantity = max(0, int(base_quantity * skill_modifier))
|
||||
quantity = max(0, int(base_quantity * total_modifier))
|
||||
if quantity > 0:
|
||||
resource = Resource(
|
||||
type=config.secondary_output,
|
||||
@ -420,12 +444,10 @@ class GameEngine:
|
||||
created_turn=self.world.current_turn,
|
||||
))
|
||||
|
||||
# Record action and improve skill
|
||||
agent.record_action(action.value)
|
||||
if skill_name:
|
||||
agent.skills.improve(skill_name, 0.015) # Skill improves with successful use
|
||||
agent.skills.improve(skill_name, 0.015)
|
||||
|
||||
# Build success message with details
|
||||
gained_str = ", ".join(f"+{r.quantity} {r.type.value}" for r in resources_gained)
|
||||
message = f"{action.value}: {gained_str}" if gained_str else f"{action.value} (nothing gained)"
|
||||
|
||||
@ -438,32 +460,239 @@ class GameEngine:
|
||||
)
|
||||
|
||||
def _get_skill_for_action(self, action: ActionType) -> Optional[str]:
|
||||
"""Get the skill name that affects a given action."""
|
||||
"""Get the skill name for an action."""
|
||||
skill_map = {
|
||||
ActionType.HUNT: "hunting",
|
||||
ActionType.GATHER: "gathering",
|
||||
ActionType.CHOP_WOOD: "woodcutting",
|
||||
ActionType.WEAVE: "crafting",
|
||||
ActionType.DRILL_OIL: "gathering", # Use gathering skill for now
|
||||
ActionType.REFINE: "crafting",
|
||||
}
|
||||
return skill_map.get(action)
|
||||
|
||||
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
||||
"""Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades.
|
||||
def _execute_pray(self, agent: Agent, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute prayer action."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.PRAY, success=False, message="Not enough energy")
|
||||
|
||||
Trading skill improves with successful trades and affects prices slightly.
|
||||
"""
|
||||
faith_gain = config.faith_gain
|
||||
agent.gain_faith(faith_gain)
|
||||
agent.religion.record_prayer(self.world.current_turn)
|
||||
agent.record_action("pray")
|
||||
|
||||
turn_log.religious_events.append({
|
||||
"type": "prayer",
|
||||
"agent_id": agent.id,
|
||||
"agent_name": agent.name,
|
||||
"religion": agent.religion.religion.value,
|
||||
"faith_gained": faith_gain,
|
||||
"new_faith": agent.stats.faith,
|
||||
})
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.PRAY,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
faith_gained=faith_gain,
|
||||
message=f"Prayed to {agent.religion.religion.value} (+{faith_gain} faith)",
|
||||
)
|
||||
|
||||
def _execute_preach(self, agent: Agent, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute preaching action to spread religion."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.PREACH, success=False, message="Not enough energy")
|
||||
|
||||
# Find nearby agents to potentially convert
|
||||
nearby = self.world.get_nearby_agents(agent, radius=4.0)
|
||||
conversions = 0
|
||||
|
||||
for target in nearby:
|
||||
if target.religion.religion == agent.religion.religion:
|
||||
# Same religion - boost their faith
|
||||
target.gain_faith(config.faith_spread // 2)
|
||||
else:
|
||||
# Different religion - try to convert
|
||||
if random.random() < config.success_chance:
|
||||
if target.religion.convert_to(agent.religion.religion, 40):
|
||||
conversions += 1
|
||||
agent.religion.record_conversion()
|
||||
self.world.total_conversions += 1
|
||||
|
||||
turn_log.religious_events.append({
|
||||
"type": "conversion",
|
||||
"preacher_id": agent.id,
|
||||
"convert_id": target.id,
|
||||
"convert_name": target.name,
|
||||
"new_religion": agent.religion.religion.value,
|
||||
})
|
||||
|
||||
agent.religion.record_sermon()
|
||||
agent.record_action("preach")
|
||||
|
||||
# Preaching also boosts own faith
|
||||
agent.gain_faith(config.faith_spread // 2)
|
||||
|
||||
if conversions > 0:
|
||||
message = f"Converted {conversions} to {agent.religion.religion.value}!"
|
||||
else:
|
||||
message = f"Preached the word of {agent.religion.religion.value}"
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.PREACH,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
faith_gained=config.faith_spread // 2,
|
||||
message=message,
|
||||
)
|
||||
|
||||
def _execute_negotiate(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute diplomatic negotiation."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.NEGOTIATE, success=False, message="Not enough energy")
|
||||
|
||||
target_faction = decision.target_faction
|
||||
if not target_faction:
|
||||
return ActionResult(action_type=ActionType.NEGOTIATE, success=False, message="No target faction")
|
||||
|
||||
faction_relations = get_faction_relations()
|
||||
my_faction = agent.diplomacy.faction
|
||||
|
||||
# Attempt negotiation
|
||||
if random.random() < config.success_chance * agent.diplomacy.diplomacy_skill:
|
||||
# Successful negotiation improves relations
|
||||
from backend.config import get_config
|
||||
boost = get_config().diplomacy.trade_relation_boost * 2
|
||||
new_relation = faction_relations.modify_relation(my_faction, target_faction, int(boost))
|
||||
|
||||
agent.diplomacy.negotiations_conducted += 1
|
||||
agent.record_action("negotiate")
|
||||
|
||||
turn_log.diplomatic_events.append({
|
||||
"type": "negotiation",
|
||||
"agent_id": agent.id,
|
||||
"agent_faction": my_faction.value,
|
||||
"target_faction": target_faction.value,
|
||||
"success": True,
|
||||
"new_relation": new_relation,
|
||||
})
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.NEGOTIATE,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
relation_change=int(boost),
|
||||
target_faction=target_faction.value,
|
||||
diplomatic_effect="improved",
|
||||
message=f"Improved relations with {target_faction.value} (+{int(boost)})",
|
||||
)
|
||||
else:
|
||||
agent.record_action("negotiate")
|
||||
return ActionResult(
|
||||
action_type=ActionType.NEGOTIATE,
|
||||
success=False,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
target_faction=target_faction.value,
|
||||
message=f"Negotiations with {target_faction.value} failed",
|
||||
)
|
||||
|
||||
def _execute_declare_war(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute war declaration."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.DECLARE_WAR, success=False, message="Not enough energy")
|
||||
|
||||
target_faction = decision.target_faction
|
||||
if not target_faction:
|
||||
return ActionResult(action_type=ActionType.DECLARE_WAR, success=False, message="No target faction")
|
||||
|
||||
faction_relations = get_faction_relations()
|
||||
my_faction = agent.diplomacy.faction
|
||||
|
||||
success = faction_relations.declare_war(my_faction, target_faction, self.world.current_turn)
|
||||
|
||||
if success:
|
||||
self.world.total_wars += 1
|
||||
agent.diplomacy.wars_declared += 1
|
||||
agent.record_action("declare_war")
|
||||
|
||||
turn_log.diplomatic_events.append({
|
||||
"type": "war_declaration",
|
||||
"agent_id": agent.id,
|
||||
"aggressor_faction": my_faction.value,
|
||||
"defender_faction": target_faction.value,
|
||||
})
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.DECLARE_WAR,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
target_faction=target_faction.value,
|
||||
diplomatic_effect="war",
|
||||
message=f"Declared WAR on {target_faction.value}!",
|
||||
)
|
||||
else:
|
||||
return ActionResult(
|
||||
action_type=ActionType.DECLARE_WAR,
|
||||
success=False,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
message=f"Already at war with {target_faction.value}",
|
||||
)
|
||||
|
||||
def _execute_make_peace(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute peace treaty."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.MAKE_PEACE, success=False, message="Not enough energy")
|
||||
|
||||
target_faction = decision.target_faction
|
||||
if not target_faction:
|
||||
return ActionResult(action_type=ActionType.MAKE_PEACE, success=False, message="No target faction")
|
||||
|
||||
faction_relations = get_faction_relations()
|
||||
my_faction = agent.diplomacy.faction
|
||||
|
||||
# Peace is harder to achieve
|
||||
if random.random() < config.success_chance * agent.diplomacy.diplomacy_skill:
|
||||
success = faction_relations.make_peace(my_faction, target_faction, self.world.current_turn)
|
||||
|
||||
if success:
|
||||
self.world.total_peace_treaties += 1
|
||||
agent.diplomacy.peace_treaties_made += 1
|
||||
agent.record_action("make_peace")
|
||||
|
||||
turn_log.diplomatic_events.append({
|
||||
"type": "peace_treaty",
|
||||
"agent_id": agent.id,
|
||||
"faction1": my_faction.value,
|
||||
"faction2": target_faction.value,
|
||||
})
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.MAKE_PEACE,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
target_faction=target_faction.value,
|
||||
diplomatic_effect="peace",
|
||||
message=f"Peace treaty signed with {target_faction.value}!",
|
||||
)
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.MAKE_PEACE,
|
||||
success=False,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
message=f"Peace negotiations with {target_faction.value} failed",
|
||||
)
|
||||
|
||||
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
||||
"""Execute a trade action."""
|
||||
config = ACTION_CONFIG[ActionType.TRADE]
|
||||
|
||||
# Handle price adjustments (no energy cost)
|
||||
if decision.adjust_order_id and decision.new_price is not None:
|
||||
return self._execute_price_adjustment(agent, decision)
|
||||
|
||||
# Handle multi-item trades
|
||||
if decision.trade_items:
|
||||
return self._execute_multi_buy(agent, decision)
|
||||
|
||||
if decision.order_id:
|
||||
# Buying single item from market
|
||||
result = self.market.execute_buy(
|
||||
buyer_id=agent.id,
|
||||
order_id=decision.order_id,
|
||||
@ -472,10 +701,8 @@ class GameEngine:
|
||||
)
|
||||
|
||||
if result.success:
|
||||
# Log the trade
|
||||
self.logger.log_trade(result.to_dict())
|
||||
|
||||
# Record sale for price history tracking
|
||||
self.market._record_sale(
|
||||
result.resource_type,
|
||||
result.total_paid // result.quantity,
|
||||
@ -483,10 +710,8 @@ class GameEngine:
|
||||
self.world.current_turn,
|
||||
)
|
||||
|
||||
# Deduct money from buyer
|
||||
agent.money -= result.total_paid
|
||||
|
||||
# Add resources to buyer
|
||||
resource = Resource(
|
||||
type=result.resource_type,
|
||||
quantity=result.quantity,
|
||||
@ -494,18 +719,25 @@ class GameEngine:
|
||||
)
|
||||
agent.add_to_inventory(resource)
|
||||
|
||||
# Add money to seller and record their trade
|
||||
seller = self.world.get_agent(result.seller_id)
|
||||
if seller:
|
||||
seller.money += result.total_paid
|
||||
seller.record_trade(result.total_paid)
|
||||
seller.skills.improve("trading", 0.02) # Seller skill improves
|
||||
seller.skills.improve("trading", 0.02)
|
||||
|
||||
# Improve faction relations from trade
|
||||
faction_relations = get_faction_relations()
|
||||
from backend.config import get_config
|
||||
boost = get_config().diplomacy.trade_relation_boost
|
||||
faction_relations.modify_relation(
|
||||
agent.diplomacy.faction,
|
||||
seller.diplomacy.faction,
|
||||
boost
|
||||
)
|
||||
|
||||
agent.spend_energy(abs(config.energy_cost))
|
||||
|
||||
# Record buyer's trade and improve skill
|
||||
agent.record_action("trade")
|
||||
agent.skills.improve("trading", 0.01) # Buyer skill improves less
|
||||
agent.skills.improve("trading", 0.01)
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.TRADE,
|
||||
@ -522,7 +754,6 @@ class GameEngine:
|
||||
)
|
||||
|
||||
elif decision.target_resource and decision.quantity > 0:
|
||||
# Selling to market (listing)
|
||||
if agent.has_resource(decision.target_resource, decision.quantity):
|
||||
agent.remove_from_inventory(decision.target_resource, decision.quantity)
|
||||
|
||||
@ -535,7 +766,7 @@ class GameEngine:
|
||||
)
|
||||
|
||||
agent.spend_energy(abs(config.energy_cost))
|
||||
agent.record_action("trade") # Track listing action
|
||||
agent.record_action("trade")
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.TRADE,
|
||||
@ -557,7 +788,7 @@ class GameEngine:
|
||||
)
|
||||
|
||||
def _execute_price_adjustment(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
||||
"""Execute a price adjustment on an existing order (no energy cost)."""
|
||||
"""Execute a price adjustment."""
|
||||
success = self.market.adjust_order_price(
|
||||
order_id=decision.adjust_order_id,
|
||||
seller_id=agent.id,
|
||||
@ -569,8 +800,8 @@ class GameEngine:
|
||||
return ActionResult(
|
||||
action_type=ActionType.TRADE,
|
||||
success=True,
|
||||
energy_spent=0, # Price adjustments are free
|
||||
message=f"Adjusted {decision.target_resource.value} price to {decision.new_price}c",
|
||||
energy_spent=0,
|
||||
message=f"Adjusted price to {decision.new_price}c",
|
||||
)
|
||||
else:
|
||||
return ActionResult(
|
||||
@ -583,17 +814,13 @@ class GameEngine:
|
||||
"""Execute a multi-item buy trade."""
|
||||
config = ACTION_CONFIG[ActionType.TRADE]
|
||||
|
||||
# Build list of purchases
|
||||
purchases = [(item.order_id, item.quantity) for item in decision.trade_items]
|
||||
|
||||
# Execute all purchases
|
||||
results = self.market.execute_multi_buy(
|
||||
buyer_id=agent.id,
|
||||
purchases=purchases,
|
||||
buyer_money=agent.money,
|
||||
)
|
||||
|
||||
# Process results
|
||||
total_paid = 0
|
||||
resources_gained = []
|
||||
items_bought = []
|
||||
@ -604,7 +831,6 @@ class GameEngine:
|
||||
agent.money -= result.total_paid
|
||||
total_paid += result.total_paid
|
||||
|
||||
# Record sale for price history
|
||||
self.market._record_sale(
|
||||
result.resource_type,
|
||||
result.total_paid // result.quantity,
|
||||
@ -621,7 +847,6 @@ class GameEngine:
|
||||
resources_gained.append(resource)
|
||||
items_bought.append(f"{result.quantity} {result.resource_type.value}")
|
||||
|
||||
# Add money to seller
|
||||
seller = self.world.get_agent(result.seller_id)
|
||||
if seller:
|
||||
seller.money += result.total_paid
|
||||
@ -687,7 +912,6 @@ class GameEngine:
|
||||
}
|
||||
|
||||
|
||||
# Global engine instance
|
||||
def get_engine() -> GameEngine:
|
||||
"""Get the global game engine instance."""
|
||||
return GameEngine()
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
The world spawns diverse agents with varied personality traits,
|
||||
skills, and starting conditions to create emergent professions
|
||||
and class inequality.
|
||||
|
||||
NEW: World now supports religion and faction systems for realistic
|
||||
social dynamics including religious diversity and geopolitical factions.
|
||||
"""
|
||||
|
||||
import random
|
||||
@ -15,6 +18,13 @@ from backend.domain.personality import (
|
||||
PersonalityTraits, Skills,
|
||||
generate_random_personality, generate_random_skills
|
||||
)
|
||||
from backend.domain.religion import (
|
||||
ReligiousBeliefs, ReligionType, generate_random_religion
|
||||
)
|
||||
from backend.domain.diplomacy import (
|
||||
AgentDiplomacy, FactionType, FactionRelations,
|
||||
generate_random_faction, reset_faction_relations, get_faction_relations
|
||||
)
|
||||
|
||||
|
||||
class TimeOfDay(Enum):
|
||||
@ -31,16 +41,14 @@ def _get_world_config_from_file():
|
||||
|
||||
@dataclass
|
||||
class WorldConfig:
|
||||
"""Configuration for the world.
|
||||
|
||||
Default values are loaded from config.json via create_world_config().
|
||||
These hardcoded defaults are only fallbacks.
|
||||
"""
|
||||
"""Configuration for the world."""
|
||||
width: int = 25
|
||||
height: int = 25
|
||||
initial_agents: int = 25
|
||||
day_steps: int = 10
|
||||
night_steps: int = 1
|
||||
oil_fields_count: int = 3 # NEW
|
||||
temple_count: int = 2 # NEW
|
||||
|
||||
|
||||
def create_world_config() -> WorldConfig:
|
||||
@ -52,9 +60,21 @@ def create_world_config() -> WorldConfig:
|
||||
initial_agents=cfg.initial_agents,
|
||||
day_steps=cfg.day_steps,
|
||||
night_steps=cfg.night_steps,
|
||||
oil_fields_count=getattr(cfg, 'oil_fields_count', 3),
|
||||
temple_count=getattr(cfg, 'temple_count', 2),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorldLocation:
|
||||
"""A special location in the world."""
|
||||
name: str
|
||||
position: Position
|
||||
location_type: str # "oil_field", "temple", "market", etc.
|
||||
faction: Optional[FactionType] = None
|
||||
religion: Optional[ReligionType] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class World:
|
||||
"""Container for all entities in the simulation."""
|
||||
@ -65,9 +85,46 @@ class World:
|
||||
step_in_day: int = 0
|
||||
time_of_day: TimeOfDay = TimeOfDay.DAY
|
||||
|
||||
# Special locations
|
||||
oil_fields: list[WorldLocation] = field(default_factory=list)
|
||||
temples: list[WorldLocation] = field(default_factory=list)
|
||||
|
||||
# Faction relations
|
||||
faction_relations: FactionRelations = field(default_factory=FactionRelations)
|
||||
|
||||
# Statistics
|
||||
total_agents_spawned: int = 0
|
||||
total_agents_died: int = 0
|
||||
total_wars: int = 0
|
||||
total_peace_treaties: int = 0
|
||||
total_conversions: int = 0
|
||||
|
||||
def _generate_locations(self) -> None:
|
||||
"""Generate special locations in the world."""
|
||||
# Generate oil fields (right side of map - "resource-rich" area)
|
||||
self.oil_fields = []
|
||||
for i in range(self.config.oil_fields_count):
|
||||
x = self.config.width * random.uniform(0.75, 0.95)
|
||||
y = self.config.height * (i + 1) / (self.config.oil_fields_count + 1)
|
||||
self.oil_fields.append(WorldLocation(
|
||||
name=f"Oil Field {i + 1}",
|
||||
position=Position(x, y),
|
||||
location_type="oil_field",
|
||||
faction=random.choice([FactionType.MOUNTAINEER, FactionType.NORTHLANDS]),
|
||||
))
|
||||
|
||||
# Generate temples (scattered across map)
|
||||
self.temples = []
|
||||
religions = [r for r in ReligionType if r != ReligionType.ATHEIST]
|
||||
for i in range(self.config.temple_count):
|
||||
x = self.config.width * random.uniform(0.3, 0.7)
|
||||
y = self.config.height * (i + 1) / (self.config.temple_count + 1)
|
||||
self.temples.append(WorldLocation(
|
||||
name=f"Temple of {religions[i % len(religions)].value.title()}",
|
||||
position=Position(x, y),
|
||||
location_type="temple",
|
||||
religion=religions[i % len(religions)],
|
||||
))
|
||||
|
||||
def spawn_agent(
|
||||
self,
|
||||
@ -76,6 +133,8 @@ class World:
|
||||
position: Optional[Position] = None,
|
||||
archetype: Optional[str] = None,
|
||||
starting_money: Optional[int] = None,
|
||||
religion: Optional[ReligiousBeliefs] = None,
|
||||
faction: Optional[AgentDiplomacy] = None,
|
||||
) -> Agent:
|
||||
"""Spawn a new agent in the world with unique personality.
|
||||
|
||||
@ -85,6 +144,8 @@ class World:
|
||||
position: Starting position (random if None)
|
||||
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
|
||||
starting_money: Starting money (random with inequality if None)
|
||||
religion: Religious beliefs (random if None)
|
||||
faction: Faction membership (random if None)
|
||||
"""
|
||||
if position is None:
|
||||
position = Position(
|
||||
@ -96,25 +157,38 @@ class World:
|
||||
personality = generate_random_personality(archetype)
|
||||
skills = generate_random_skills(personality)
|
||||
|
||||
# Generate religion if not provided
|
||||
if religion is None:
|
||||
religion = generate_random_religion(archetype)
|
||||
|
||||
# Generate faction if not provided
|
||||
if faction is None:
|
||||
faction = generate_random_faction(archetype)
|
||||
|
||||
# Variable starting money for class inequality
|
||||
# Some agents start with more, some with less
|
||||
if starting_money is None:
|
||||
from backend.config import get_config
|
||||
base_money = get_config().world.starting_money
|
||||
# Random multiplier: 0.3x to 2.0x base money
|
||||
# This creates natural class inequality
|
||||
money_multiplier = random.uniform(0.3, 2.0)
|
||||
|
||||
# Traders start with more money (their capital)
|
||||
if personality.trade_preference > 1.3:
|
||||
money_multiplier *= 1.5
|
||||
|
||||
# Oil-controlling factions have wealth bonus
|
||||
if faction.faction == FactionType.MOUNTAINEER:
|
||||
money_multiplier *= 1.3
|
||||
|
||||
starting_money = int(base_money * money_multiplier)
|
||||
|
||||
agent = Agent(
|
||||
name=name or f"Villager_{self.total_agents_spawned + 1}",
|
||||
profession=Profession.VILLAGER, # Will be updated based on personality
|
||||
profession=Profession.VILLAGER,
|
||||
position=position,
|
||||
personality=personality,
|
||||
skills=skills,
|
||||
religion=religion,
|
||||
diplomacy=faction,
|
||||
money=starting_money,
|
||||
)
|
||||
|
||||
@ -129,12 +203,35 @@ class World:
|
||||
return agent
|
||||
return None
|
||||
|
||||
def get_agents_by_faction(self, faction: FactionType) -> list[Agent]:
|
||||
"""Get all living agents in a faction."""
|
||||
return [
|
||||
a for a in self.agents
|
||||
if a.is_alive() and not a.is_corpse() and a.diplomacy.faction == faction
|
||||
]
|
||||
|
||||
def get_agents_by_religion(self, religion: ReligionType) -> list[Agent]:
|
||||
"""Get all living agents of a religion."""
|
||||
return [
|
||||
a for a in self.agents
|
||||
if a.is_alive() and not a.is_corpse() and a.religion.religion == religion
|
||||
]
|
||||
|
||||
def get_nearby_agents(self, agent: Agent, radius: float = 3.0) -> list[Agent]:
|
||||
"""Get living agents near a given agent."""
|
||||
nearby = []
|
||||
for other in self.agents:
|
||||
if other.id == agent.id:
|
||||
continue
|
||||
if not other.is_alive() or other.is_corpse():
|
||||
continue
|
||||
if agent.position.distance_to(other.position) <= radius:
|
||||
nearby.append(other)
|
||||
return nearby
|
||||
|
||||
def remove_dead_agents(self) -> list[Agent]:
|
||||
"""Remove all dead agents from the world. Returns list of removed agents.
|
||||
Note: This is now handled by the engine's corpse system for visualization.
|
||||
"""
|
||||
"""Remove all dead agents from the world."""
|
||||
dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()]
|
||||
# Don't actually remove here - let the engine handle corpse visualization
|
||||
return dead_agents
|
||||
|
||||
def advance_time(self) -> None:
|
||||
@ -148,11 +245,13 @@ 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."""
|
||||
@ -167,13 +266,25 @@ class World:
|
||||
living = self.get_living_agents()
|
||||
total_money = sum(a.money for a in living)
|
||||
|
||||
# Count emergent professions (updated based on current skills)
|
||||
# Count emergent professions
|
||||
profession_counts = {}
|
||||
for agent in living:
|
||||
agent._update_profession() # Update based on current state
|
||||
agent._update_profession()
|
||||
prof = agent.profession.value
|
||||
profession_counts[prof] = profession_counts.get(prof, 0) + 1
|
||||
|
||||
# Count religions
|
||||
religion_counts = {}
|
||||
for agent in living:
|
||||
rel = agent.religion.religion.value
|
||||
religion_counts[rel] = religion_counts.get(rel, 0) + 1
|
||||
|
||||
# Count factions
|
||||
faction_counts = {}
|
||||
for agent in living:
|
||||
fac = agent.diplomacy.faction.value
|
||||
faction_counts[fac] = faction_counts.get(fac, 0) + 1
|
||||
|
||||
# Calculate wealth inequality metrics
|
||||
if living:
|
||||
moneys = sorted([a.money for a in living])
|
||||
@ -182,15 +293,21 @@ class World:
|
||||
richest = moneys[-1] if moneys else 0
|
||||
poorest = moneys[0] if moneys else 0
|
||||
|
||||
# Gini coefficient for inequality (0 = perfect equality, 1 = max inequality)
|
||||
# Gini coefficient
|
||||
n = len(moneys)
|
||||
if n > 1 and total_money > 0:
|
||||
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
||||
gini = sum_of_diffs / (2 * n * total_money)
|
||||
else:
|
||||
gini = 0
|
||||
|
||||
# Average faith
|
||||
avg_faith = sum(a.stats.faith for a in living) / len(living)
|
||||
else:
|
||||
avg_money = median_money = richest = poorest = gini = 0
|
||||
avg_money = median_money = richest = poorest = gini = avg_faith = 0
|
||||
|
||||
# War status
|
||||
active_wars = len(self.faction_relations.active_wars)
|
||||
|
||||
return {
|
||||
"current_turn": self.current_turn,
|
||||
@ -202,12 +319,20 @@ class World:
|
||||
"total_agents_died": self.total_agents_died,
|
||||
"total_money_in_circulation": total_money,
|
||||
"professions": profession_counts,
|
||||
# Wealth inequality metrics
|
||||
# Wealth metrics
|
||||
"avg_money": round(avg_money, 1),
|
||||
"median_money": median_money,
|
||||
"richest_agent": richest,
|
||||
"poorest_agent": poorest,
|
||||
"gini_coefficient": round(gini, 3),
|
||||
# NEW: Religion and diplomacy stats
|
||||
"religions": religion_counts,
|
||||
"factions": faction_counts,
|
||||
"active_wars": active_wars,
|
||||
"avg_faith": round(avg_faith, 1),
|
||||
"total_wars": self.total_wars,
|
||||
"total_peace_treaties": self.total_peace_treaties,
|
||||
"total_conversions": self.total_conversions,
|
||||
}
|
||||
|
||||
def get_state_snapshot(self) -> dict:
|
||||
@ -220,21 +345,34 @@ class World:
|
||||
"world_size": {"width": self.config.width, "height": self.config.height},
|
||||
"agents": [a.to_dict() for a in self.agents],
|
||||
"statistics": self.get_statistics(),
|
||||
# NEW: Special locations
|
||||
"oil_fields": [
|
||||
{"name": l.name, "position": l.position.to_dict(), "faction": l.faction.value if l.faction else None}
|
||||
for l in self.oil_fields
|
||||
],
|
||||
"temples": [
|
||||
{"name": l.name, "position": l.position.to_dict(), "religion": l.religion.value if l.religion else None}
|
||||
for l in self.temples
|
||||
],
|
||||
# NEW: Faction relations summary
|
||||
"faction_relations": self.faction_relations.to_dict(),
|
||||
}
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize the world with diverse starting agents.
|
||||
|
||||
Creates a mix of agent archetypes to seed profession diversity:
|
||||
- Some hunters (risk-takers who hunt)
|
||||
- Some gatherers (cautious resource collectors)
|
||||
- Some traders (market-focused wealth builders)
|
||||
- Some generalists (balanced approach)
|
||||
Creates a mix of agent archetypes to seed profession diversity.
|
||||
Now also seeds religious and faction diversity.
|
||||
"""
|
||||
# Reset faction relations
|
||||
self.faction_relations = reset_faction_relations()
|
||||
|
||||
# Generate special locations
|
||||
self._generate_locations()
|
||||
|
||||
n = self.config.initial_agents
|
||||
|
||||
# Distribute archetypes for diversity
|
||||
# ~15% hunters, ~15% gatherers, ~15% traders, ~10% woodcutters, 45% random
|
||||
archetypes = (
|
||||
["hunter"] * max(1, n // 7) +
|
||||
["gatherer"] * max(1, n // 7) +
|
||||
@ -242,13 +380,33 @@ class World:
|
||||
["woodcutter"] * max(1, n // 10)
|
||||
)
|
||||
|
||||
# Fill remaining slots with random (no archetype)
|
||||
while len(archetypes) < n:
|
||||
archetypes.append(None)
|
||||
|
||||
# Shuffle to randomize positions
|
||||
random.shuffle(archetypes)
|
||||
|
||||
for archetype in archetypes:
|
||||
self.spawn_agent(archetype=archetype)
|
||||
|
||||
|
||||
# Set up some initial faction tensions for drama
|
||||
self._create_initial_tensions()
|
||||
|
||||
def _create_initial_tensions(self) -> None:
|
||||
"""Create some initial diplomatic tensions for realistic starting conditions."""
|
||||
# Some factions have historical rivalries
|
||||
rivalries = [
|
||||
(FactionType.NORTHLANDS, FactionType.RIVERFOLK, -15),
|
||||
(FactionType.FORESTKIN, FactionType.MOUNTAINEER, -10),
|
||||
]
|
||||
|
||||
for faction1, faction2, modifier in rivalries:
|
||||
self.faction_relations.modify_relation(faction1, faction2, modifier)
|
||||
|
||||
# Some factions have good relations
|
||||
friendships = [
|
||||
(FactionType.RIVERFOLK, FactionType.PLAINSMEN, 10),
|
||||
(FactionType.PLAINSMEN, FactionType.FORESTKIN, 15),
|
||||
]
|
||||
|
||||
for faction1, faction2, modifier in friendships:
|
||||
self.faction_relations.modify_relation(faction1, faction2, modifier)
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
from .resources import ResourceType, Resource, RESOURCE_EFFECTS
|
||||
from .action import ActionType, ActionConfig, ActionResult, ACTION_CONFIG
|
||||
from .agent import Agent, AgentStats, Position
|
||||
from .religion import ReligionType, ReligiousBeliefs
|
||||
from .diplomacy import FactionType, AgentDiplomacy, FactionRelations
|
||||
|
||||
__all__ = [
|
||||
"ResourceType",
|
||||
@ -15,5 +17,11 @@ __all__ = [
|
||||
"Agent",
|
||||
"AgentStats",
|
||||
"Position",
|
||||
# Religion
|
||||
"ReligionType",
|
||||
"ReligiousBeliefs",
|
||||
# Diplomacy
|
||||
"FactionType",
|
||||
"AgentDiplomacy",
|
||||
"FactionRelations",
|
||||
]
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
class ActionType(Enum):
|
||||
"""Types of actions an agent can perform."""
|
||||
# Basic survival actions
|
||||
SLEEP = "sleep" # Night action - restores energy
|
||||
REST = "rest" # Day action - restores some energy
|
||||
HUNT = "hunt" # Produces meat and hide
|
||||
@ -25,6 +26,20 @@ class ActionType(Enum):
|
||||
BUILD_FIRE = "build_fire" # Consumes wood, provides heat
|
||||
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
|
||||
@ -40,14 +55,13 @@ class ActionConfig:
|
||||
secondary_max: int = 0
|
||||
requires_resource: Optional[ResourceType] = None
|
||||
requires_quantity: int = 0
|
||||
# NEW: Faith effects
|
||||
faith_gain: int = 0
|
||||
faith_spread: int = 0
|
||||
|
||||
|
||||
def get_action_config() -> dict[ActionType, ActionConfig]:
|
||||
"""Get action configurations from the global config.
|
||||
|
||||
This function dynamically builds ACTION_CONFIG from config.json values.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
"""Get action configurations from the global config."""
|
||||
from backend.config import get_config
|
||||
|
||||
config = get_config()
|
||||
@ -55,10 +69,10 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
|
||||
|
||||
return {
|
||||
ActionType.SLEEP: ActionConfig(
|
||||
energy_cost=actions.sleep_energy, # Restores energy
|
||||
energy_cost=actions.sleep_energy,
|
||||
),
|
||||
ActionType.REST: ActionConfig(
|
||||
energy_cost=actions.rest_energy, # Restores some energy
|
||||
energy_cost=actions.rest_energy,
|
||||
),
|
||||
ActionType.HUNT: ActionConfig(
|
||||
energy_cost=actions.hunt_energy,
|
||||
@ -112,6 +126,53 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
|
||||
ActionType.CONSUME: ActionConfig(
|
||||
energy_cost=0,
|
||||
),
|
||||
# NEW: Oil industry actions
|
||||
ActionType.DRILL_OIL: ActionConfig(
|
||||
energy_cost=actions.drill_oil_energy,
|
||||
success_chance=actions.drill_oil_success,
|
||||
min_output=actions.drill_oil_min,
|
||||
max_output=actions.drill_oil_max,
|
||||
output_resource=ResourceType.OIL,
|
||||
),
|
||||
ActionType.REFINE: ActionConfig(
|
||||
energy_cost=actions.refine_energy,
|
||||
success_chance=1.0,
|
||||
min_output=1,
|
||||
max_output=1,
|
||||
output_resource=ResourceType.FUEL,
|
||||
requires_resource=ResourceType.OIL,
|
||||
requires_quantity=2, # 2 oil -> 1 fuel
|
||||
),
|
||||
ActionType.BURN_FUEL: ActionConfig(
|
||||
energy_cost=-1, # Minimal effort to burn fuel
|
||||
success_chance=1.0,
|
||||
requires_resource=ResourceType.FUEL,
|
||||
requires_quantity=1,
|
||||
),
|
||||
# NEW: Religious actions
|
||||
ActionType.PRAY: ActionConfig(
|
||||
energy_cost=actions.pray_energy,
|
||||
success_chance=1.0,
|
||||
faith_gain=actions.pray_faith_gain,
|
||||
),
|
||||
ActionType.PREACH: ActionConfig(
|
||||
energy_cost=actions.preach_energy,
|
||||
success_chance=actions.preach_convert_chance,
|
||||
faith_spread=actions.preach_faith_spread,
|
||||
),
|
||||
# NEW: Diplomatic actions
|
||||
ActionType.NEGOTIATE: ActionConfig(
|
||||
energy_cost=actions.negotiate_energy,
|
||||
success_chance=0.7, # Not always successful
|
||||
),
|
||||
ActionType.DECLARE_WAR: ActionConfig(
|
||||
energy_cost=actions.declare_war_energy,
|
||||
success_chance=1.0, # Always succeeds (but has consequences)
|
||||
),
|
||||
ActionType.MAKE_PEACE: ActionConfig(
|
||||
energy_cost=actions.make_peace_energy,
|
||||
success_chance=0.5, # Harder to make peace than war
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@ -133,8 +194,6 @@ def reset_action_config_cache() -> None:
|
||||
_action_config_cache = None
|
||||
|
||||
|
||||
# For backwards compatibility - this is a property-like access
|
||||
# that returns fresh config each time (use get_cached_action_config for performance)
|
||||
class _ActionConfigAccessor:
|
||||
"""Accessor class that provides dict-like access to action configs."""
|
||||
|
||||
@ -161,6 +220,21 @@ class _ActionConfigAccessor:
|
||||
ACTION_CONFIG = _ActionConfigAccessor()
|
||||
|
||||
|
||||
# Action categories for AI decision making
|
||||
SURVIVAL_ACTIONS = {
|
||||
ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||
ActionType.GET_WATER, ActionType.BUILD_FIRE, ActionType.CONSUME
|
||||
}
|
||||
PRODUCTION_ACTIONS = {
|
||||
ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||
ActionType.GET_WATER, ActionType.DRILL_OIL
|
||||
}
|
||||
CRAFTING_ACTIONS = {ActionType.WEAVE, ActionType.REFINE}
|
||||
RELIGIOUS_ACTIONS = {ActionType.PRAY, ActionType.PREACH}
|
||||
DIPLOMATIC_ACTIONS = {ActionType.NEGOTIATE, ActionType.DECLARE_WAR, ActionType.MAKE_PEACE}
|
||||
ECONOMIC_ACTIONS = {ActionType.TRADE}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionResult:
|
||||
"""Result of executing an action."""
|
||||
@ -170,8 +244,14 @@ class ActionResult:
|
||||
resources_gained: list = field(default_factory=list)
|
||||
resources_consumed: list = field(default_factory=list)
|
||||
heat_gained: int = 0
|
||||
faith_gained: int = 0 # NEW
|
||||
relation_change: int = 0 # NEW
|
||||
message: str = ""
|
||||
|
||||
# NEW: Diplomatic effects
|
||||
target_faction: Optional[str] = None
|
||||
diplomatic_effect: Optional[str] = None # "war", "peace", "improved", "degraded"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API serialization."""
|
||||
return {
|
||||
@ -187,5 +267,9 @@ class ActionResult:
|
||||
for r in self.resources_consumed
|
||||
],
|
||||
"heat_gained": self.heat_gained,
|
||||
"faith_gained": self.faith_gained,
|
||||
"relation_change": self.relation_change,
|
||||
"target_faction": self.target_faction,
|
||||
"diplomatic_effect": self.diplomatic_effect,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
Agent stats are loaded dynamically from the global config.
|
||||
Each agent now has unique personality traits and skills that create
|
||||
emergent professions and behavioral diversity.
|
||||
|
||||
NEW: Agents now have religion and faction membership for realistic
|
||||
social dynamics including religious beliefs and geopolitical allegiances.
|
||||
"""
|
||||
|
||||
import math
|
||||
@ -17,6 +20,8 @@ from .personality import (
|
||||
PersonalityTraits, Skills, ProfessionType,
|
||||
determine_profession
|
||||
)
|
||||
from .religion import ReligiousBeliefs
|
||||
from .diplomacy import AgentDiplomacy
|
||||
|
||||
|
||||
def _get_agent_stats_config():
|
||||
@ -33,6 +38,8 @@ class Profession(Enum):
|
||||
WOODCUTTER = "woodcutter"
|
||||
TRADER = "trader"
|
||||
CRAFTER = "crafter"
|
||||
OIL_WORKER = "oil_worker" # NEW: Oil industry worker
|
||||
PRIEST = "priest" # NEW: Religious leader
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -80,18 +87,21 @@ class AgentStats:
|
||||
hunger: int = field(default=80)
|
||||
thirst: int = field(default=70)
|
||||
heat: int = field(default=100)
|
||||
faith: int = field(default=50) # NEW: Religious faith level
|
||||
|
||||
# Maximum values - loaded from config
|
||||
MAX_ENERGY: int = field(default=50)
|
||||
MAX_HUNGER: int = field(default=100)
|
||||
MAX_THIRST: int = field(default=100)
|
||||
MAX_HEAT: int = field(default=100)
|
||||
MAX_FAITH: int = field(default=100) # NEW
|
||||
|
||||
# Passive decay rates per turn - loaded from config
|
||||
ENERGY_DECAY: int = field(default=1)
|
||||
HUNGER_DECAY: int = field(default=2)
|
||||
THIRST_DECAY: int = field(default=3)
|
||||
HEAT_DECAY: int = field(default=2)
|
||||
FAITH_DECAY: int = field(default=1) # NEW
|
||||
|
||||
# Critical threshold - loaded from config
|
||||
CRITICAL_THRESHOLD: float = field(default=0.25)
|
||||
@ -105,6 +115,9 @@ class AgentStats:
|
||||
# Clothes reduce heat loss by 50%
|
||||
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."""
|
||||
@ -135,16 +148,31 @@ class AgentStats:
|
||||
"""Check if agent has enough energy to perform an action."""
|
||||
return self.energy >= abs(energy_required)
|
||||
|
||||
def gain_faith(self, amount: int) -> None:
|
||||
"""Increase faith level."""
|
||||
self.faith = min(self.MAX_FAITH, self.faith + amount)
|
||||
|
||||
def lose_faith(self, amount: int) -> None:
|
||||
"""Decrease faith level."""
|
||||
self.faith = max(0, self.faith - amount)
|
||||
|
||||
@property
|
||||
def is_zealot(self) -> bool:
|
||||
"""Check if agent has zealot-level faith."""
|
||||
return self.faith >= int(self.MAX_FAITH * 0.80)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"energy": self.energy,
|
||||
"hunger": self.hunger,
|
||||
"thirst": self.thirst,
|
||||
"heat": self.heat,
|
||||
"faith": self.faith,
|
||||
"max_energy": self.MAX_ENERGY,
|
||||
"max_hunger": self.MAX_HUNGER,
|
||||
"max_thirst": self.MAX_THIRST,
|
||||
"max_heat": self.MAX_HEAT,
|
||||
"max_faith": self.MAX_FAITH,
|
||||
}
|
||||
|
||||
|
||||
@ -156,14 +184,17 @@ def create_agent_stats() -> AgentStats:
|
||||
hunger=config.start_hunger,
|
||||
thirst=config.start_thirst,
|
||||
heat=config.start_heat,
|
||||
faith=getattr(config, 'start_faith', 50),
|
||||
MAX_ENERGY=config.max_energy,
|
||||
MAX_HUNGER=config.max_hunger,
|
||||
MAX_THIRST=config.max_thirst,
|
||||
MAX_HEAT=config.max_heat,
|
||||
MAX_FAITH=getattr(config, 'max_faith', 100),
|
||||
ENERGY_DECAY=config.energy_decay,
|
||||
HUNGER_DECAY=config.hunger_decay,
|
||||
THIRST_DECAY=config.thirst_decay,
|
||||
HEAT_DECAY=config.heat_decay,
|
||||
FAITH_DECAY=getattr(config, 'faith_decay', 1),
|
||||
CRITICAL_THRESHOLD=config.critical_threshold,
|
||||
)
|
||||
|
||||
@ -171,9 +202,10 @@ def create_agent_stats() -> AgentStats:
|
||||
@dataclass
|
||||
class AgentAction:
|
||||
"""Current action being performed by an agent."""
|
||||
action_type: str = "" # e.g., "hunt", "gather", "trade", "rest"
|
||||
action_type: str = "" # e.g., "hunt", "gather", "trade", "rest", "pray"
|
||||
target_position: Optional[Position] = None
|
||||
target_resource: Optional[str] = None
|
||||
target_agent: Optional[str] = None # NEW: For diplomatic/religious actions
|
||||
progress: float = 0.0 # 0.0 to 1.0
|
||||
is_moving: bool = False
|
||||
message: str = ""
|
||||
@ -183,6 +215,7 @@ class AgentAction:
|
||||
"action_type": self.action_type,
|
||||
"target_position": self.target_position.to_dict() if self.target_position else None,
|
||||
"target_resource": self.target_resource,
|
||||
"target_agent": self.target_agent,
|
||||
"progress": round(self.progress, 2),
|
||||
"is_moving": self.is_moving,
|
||||
"message": self.message,
|
||||
@ -191,16 +224,27 @@ class AgentAction:
|
||||
|
||||
# Action location mappings (relative positions on the map for each action type)
|
||||
ACTION_LOCATIONS = {
|
||||
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)}, # Right side (forest)
|
||||
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)}, # Left side (bushes)
|
||||
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)}, # Far right (forest)
|
||||
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)}, # Far left (river)
|
||||
"weave": {"zone": "village", "offset_range": (0.4, 0.6)}, # Center (village)
|
||||
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)},
|
||||
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)},
|
||||
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)},
|
||||
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)},
|
||||
"weave": {"zone": "village", "offset_range": (0.4, 0.6)},
|
||||
"build_fire": {"zone": "village", "offset_range": (0.45, 0.55)},
|
||||
"trade": {"zone": "market", "offset_range": (0.5, 0.6)}, # Center (market)
|
||||
"burn_fuel": {"zone": "village", "offset_range": (0.45, 0.55)},
|
||||
"trade": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||
"rest": {"zone": "home", "offset_range": (0.4, 0.6)},
|
||||
"sleep": {"zone": "home", "offset_range": (0.4, 0.6)},
|
||||
"consume": {"zone": "current", "offset_range": (0, 0)}, # Stay in place
|
||||
"consume": {"zone": "current", "offset_range": (0, 0)},
|
||||
# NEW: Oil industry locations
|
||||
"drill_oil": {"zone": "oil_field", "offset_range": (0.8, 0.95)},
|
||||
"refine": {"zone": "refinery", "offset_range": (0.7, 0.85)},
|
||||
# NEW: Religious locations
|
||||
"pray": {"zone": "temple", "offset_range": (0.45, 0.55)},
|
||||
"preach": {"zone": "village", "offset_range": (0.4, 0.6)},
|
||||
# NEW: Diplomatic locations
|
||||
"negotiate": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||
"declare_war": {"zone": "village", "offset_range": (0.5, 0.5)},
|
||||
"make_peace": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||
}
|
||||
|
||||
|
||||
@ -217,57 +261,76 @@ class Agent:
|
||||
Stats, inventory slots, and starting money are loaded from config.json.
|
||||
Each agent now has unique personality traits and skills that create
|
||||
emergent behaviors and professions.
|
||||
|
||||
NEW: Agents now have religious beliefs and faction membership.
|
||||
"""
|
||||
id: str = field(default_factory=lambda: str(uuid4())[:8])
|
||||
name: str = ""
|
||||
profession: Profession = Profession.VILLAGER # Now derived from personality/skills
|
||||
profession: Profession = Profession.VILLAGER
|
||||
position: Position = field(default_factory=Position)
|
||||
stats: AgentStats = field(default_factory=create_agent_stats)
|
||||
inventory: list[Resource] = field(default_factory=list)
|
||||
money: int = field(default=-1) # -1 signals to use config value
|
||||
money: int = field(default=-1)
|
||||
|
||||
# Personality and skills - create agent diversity
|
||||
# Personality and skills
|
||||
personality: PersonalityTraits = field(default_factory=PersonalityTraits)
|
||||
skills: Skills = field(default_factory=Skills)
|
||||
|
||||
# NEW: Religion and diplomacy
|
||||
religion: ReligiousBeliefs = field(default_factory=ReligiousBeliefs)
|
||||
diplomacy: AgentDiplomacy = field(default_factory=AgentDiplomacy)
|
||||
|
||||
# Movement and action tracking
|
||||
home_position: Position = field(default_factory=Position)
|
||||
current_action: AgentAction = field(default_factory=AgentAction)
|
||||
last_action_result: str = ""
|
||||
|
||||
# Death tracking for corpse visualization
|
||||
death_turn: int = -1 # Turn when agent died, -1 if alive
|
||||
death_reason: str = "" # Cause of death
|
||||
death_turn: int = -1
|
||||
death_reason: str = ""
|
||||
|
||||
# Statistics tracking for profession determination
|
||||
actions_performed: dict = field(default_factory=lambda: {
|
||||
"hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0
|
||||
"hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0,
|
||||
"drill_oil": 0, "refine": 0, "pray": 0, "preach": 0,
|
||||
"negotiate": 0, "declare_war": 0, "make_peace": 0,
|
||||
})
|
||||
total_trades_completed: int = 0
|
||||
total_money_earned: int = 0
|
||||
|
||||
# Configuration - loaded from config
|
||||
INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value
|
||||
MOVE_SPEED: float = 0.8 # Grid cells per turn
|
||||
INVENTORY_SLOTS: int = field(default=-1)
|
||||
MOVE_SPEED: float = 0.8
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.name:
|
||||
self.name = f"Agent_{self.id}"
|
||||
# Set home position to initial position
|
||||
self.home_position = self.position.copy()
|
||||
|
||||
# Load config values if defaults were used
|
||||
config = _get_world_config()
|
||||
if self.money == -1:
|
||||
self.money = config.starting_money
|
||||
if self.INVENTORY_SLOTS == -1:
|
||||
self.INVENTORY_SLOTS = config.inventory_slots
|
||||
|
||||
# Update profession based on personality and skills
|
||||
self._update_profession()
|
||||
|
||||
def _update_profession(self) -> None:
|
||||
"""Update profession based on personality and skills."""
|
||||
"""Update profession based on personality, skills, and activities."""
|
||||
# Check for specialized professions first
|
||||
|
||||
# High religious activity = Priest
|
||||
if self.actions_performed.get("pray", 0) + self.actions_performed.get("preach", 0) > 10:
|
||||
if self.stats.faith > 70:
|
||||
self.profession = Profession.PRIEST
|
||||
return
|
||||
|
||||
# High oil activity = Oil Worker
|
||||
if self.actions_performed.get("drill_oil", 0) + self.actions_performed.get("refine", 0) > 10:
|
||||
self.profession = Profession.OIL_WORKER
|
||||
return
|
||||
|
||||
# Standard profession determination
|
||||
prof_type = determine_profession(self.personality, self.skills)
|
||||
profession_map = {
|
||||
ProfessionType.HUNTER: Profession.HUNTER,
|
||||
@ -298,7 +361,7 @@ class Agent:
|
||||
)
|
||||
|
||||
def is_corpse(self) -> bool:
|
||||
"""Check if this agent is a corpse (died but still visible)."""
|
||||
"""Check if this agent is a corpse."""
|
||||
return self.death_turn >= 0
|
||||
|
||||
def can_act(self) -> bool:
|
||||
@ -309,6 +372,14 @@ class Agent:
|
||||
"""Check if agent has clothes equipped."""
|
||||
return any(r.type == ResourceType.CLOTHES for r in self.inventory)
|
||||
|
||||
def has_oil(self) -> bool:
|
||||
"""Check if agent has oil."""
|
||||
return any(r.type == ResourceType.OIL for r in self.inventory)
|
||||
|
||||
def has_fuel(self) -> bool:
|
||||
"""Check if agent has fuel."""
|
||||
return any(r.type == ResourceType.FUEL for r in self.inventory)
|
||||
|
||||
def inventory_space(self) -> int:
|
||||
"""Get remaining inventory slots."""
|
||||
total_items = sum(r.quantity for r in self.inventory)
|
||||
@ -325,22 +396,20 @@ class Agent:
|
||||
world_height: int,
|
||||
message: str = "",
|
||||
target_resource: Optional[str] = None,
|
||||
target_agent: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Set the current action and calculate target position."""
|
||||
location = ACTION_LOCATIONS.get(action_type, {"zone": "current", "offset_range": (0, 0)})
|
||||
|
||||
if location["zone"] == "current":
|
||||
# Stay in place
|
||||
target = self.position.copy()
|
||||
is_moving = False
|
||||
else:
|
||||
# Calculate target position based on action zone
|
||||
offset_range = location["offset_range"]
|
||||
offset_min = float(offset_range[0])
|
||||
offset_max = float(offset_range[1])
|
||||
offset_min = float(offset_range[0]) if offset_range else 0.0
|
||||
offset_max = float(offset_range[1]) if offset_range else 0.0
|
||||
target_x = world_width * random.uniform(offset_min, offset_max)
|
||||
|
||||
# Keep y position somewhat consistent but allow some variation
|
||||
target_y = self.home_position.y + random.uniform(-2, 2)
|
||||
target_y = max(0.5, min(world_height - 0.5, target_y))
|
||||
|
||||
@ -351,6 +420,7 @@ class Agent:
|
||||
action_type=action_type,
|
||||
target_position=target,
|
||||
target_resource=target_resource,
|
||||
target_agent=target_agent,
|
||||
progress=0.0,
|
||||
is_moving=is_moving,
|
||||
message=message,
|
||||
@ -365,7 +435,7 @@ class Agent:
|
||||
)
|
||||
if reached:
|
||||
self.current_action.is_moving = False
|
||||
self.current_action.progress = 0.5 # At location, doing action
|
||||
self.current_action.progress = 0.5
|
||||
|
||||
def complete_action(self, success: bool, message: str) -> None:
|
||||
"""Mark current action as complete."""
|
||||
@ -382,13 +452,11 @@ class Agent:
|
||||
|
||||
quantity_to_add = min(resource.quantity, space)
|
||||
|
||||
# Try to stack with existing resource of same type
|
||||
for existing in self.inventory:
|
||||
if existing.type == resource.type:
|
||||
existing.quantity += quantity_to_add
|
||||
return quantity_to_add
|
||||
|
||||
# Add as new stack
|
||||
new_resource = Resource(
|
||||
type=resource.type,
|
||||
quantity=quantity_to_add,
|
||||
@ -452,7 +520,7 @@ class Agent:
|
||||
return True
|
||||
|
||||
def apply_heat(self, amount: int) -> None:
|
||||
"""Apply heat from a fire."""
|
||||
"""Apply heat from a fire or fuel."""
|
||||
self.stats.heat = min(self.stats.MAX_HEAT, self.stats.heat + amount)
|
||||
|
||||
def restore_energy(self, amount: int) -> None:
|
||||
@ -466,8 +534,13 @@ class Agent:
|
||||
self.stats.energy -= amount
|
||||
return True
|
||||
|
||||
def gain_faith(self, amount: int) -> None:
|
||||
"""Increase faith from religious activity."""
|
||||
self.stats.gain_faith(amount)
|
||||
self.religion.gain_faith(amount)
|
||||
|
||||
def decay_inventory(self, current_turn: int) -> list[Resource]:
|
||||
"""Remove expired resources from inventory. Returns list of removed resources."""
|
||||
"""Remove expired resources from inventory."""
|
||||
expired = []
|
||||
for resource in self.inventory[:]:
|
||||
if resource.is_expired(current_turn):
|
||||
@ -478,15 +551,38 @@ class Agent:
|
||||
def apply_passive_decay(self) -> None:
|
||||
"""Apply passive stat decay for this turn."""
|
||||
self.stats.apply_passive_decay(has_clothes=self.has_clothes())
|
||||
self.religion.apply_decay()
|
||||
|
||||
def mark_dead(self, turn: int, reason: str) -> None:
|
||||
"""Mark this agent as dead."""
|
||||
self.death_turn = turn
|
||||
self.death_reason = reason
|
||||
|
||||
def shares_religion_with(self, other: "Agent") -> bool:
|
||||
"""Check if agent shares religion with another."""
|
||||
return self.religion.religion == other.religion.religion
|
||||
|
||||
def shares_faction_with(self, other: "Agent") -> bool:
|
||||
"""Check if agent shares faction with another."""
|
||||
return self.diplomacy.faction == other.diplomacy.faction
|
||||
|
||||
def get_trade_modifier_for(self, other: "Agent") -> float:
|
||||
"""Get combined trade modifier when trading with another agent."""
|
||||
# Religion modifier
|
||||
religion_mod = self.religion.get_trade_modifier(other.religion)
|
||||
|
||||
# Faction modifier (from global relations)
|
||||
from .diplomacy import get_faction_relations
|
||||
faction_relations = get_faction_relations()
|
||||
faction_mod = faction_relations.get_trade_modifier(
|
||||
self.diplomacy.faction,
|
||||
other.diplomacy.faction
|
||||
)
|
||||
|
||||
return religion_mod * faction_mod
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API serialization."""
|
||||
# Update profession before serializing
|
||||
self._update_profession()
|
||||
|
||||
return {
|
||||
@ -505,10 +601,13 @@ class Agent:
|
||||
"last_action_result": self.last_action_result,
|
||||
"death_turn": self.death_turn,
|
||||
"death_reason": self.death_reason,
|
||||
# New fields for agent diversity
|
||||
# Personality and skills
|
||||
"personality": self.personality.to_dict(),
|
||||
"skills": self.skills.to_dict(),
|
||||
"actions_performed": self.actions_performed.copy(),
|
||||
"total_trades": self.total_trades_completed,
|
||||
"total_money_earned": self.total_money_earned,
|
||||
# NEW: Religion and diplomacy
|
||||
"religion": self.religion.to_dict(),
|
||||
"diplomacy": self.diplomacy.to_dict(),
|
||||
}
|
||||
|
||||
515
backend/domain/diplomacy.py
Normal file
515
backend/domain/diplomacy.py
Normal file
@ -0,0 +1,515 @@
|
||||
"""Diplomacy system for the Village Simulation.
|
||||
|
||||
Creates faction-based politics with:
|
||||
- Multiple factions that agents belong to
|
||||
- Relations between factions (0-100)
|
||||
- War and peace mechanics
|
||||
- Trade agreements and alliances
|
||||
- Real-world style geopolitics
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Set
|
||||
|
||||
|
||||
class FactionType(Enum):
|
||||
"""Types of factions in the simulation.
|
||||
|
||||
Like real-world nations/groups with distinct characteristics.
|
||||
"""
|
||||
NEUTRAL = "neutral" # Unaffiliated agents
|
||||
NORTHLANDS = "northlands" # Northern faction - hardy, value warmth
|
||||
RIVERFOLK = "riverfolk" # River faction - trade-focused, value water
|
||||
FORESTKIN = "forestkin" # Forest faction - hunters and gatherers
|
||||
MOUNTAINEER = "mountaineer" # Mountain faction - miners, value resources
|
||||
PLAINSMEN = "plainsmen" # Plains faction - farmers, balanced
|
||||
|
||||
|
||||
# Faction characteristics
|
||||
FACTION_TRAITS = {
|
||||
FactionType.NEUTRAL: {
|
||||
"description": "Unaffiliated individuals",
|
||||
"bonus_resource": None,
|
||||
"aggression": 0.0,
|
||||
"diplomacy_skill": 0.5,
|
||||
"trade_preference": 1.0,
|
||||
"color": "#808080",
|
||||
},
|
||||
FactionType.NORTHLANDS: {
|
||||
"description": "Hardy people of the North",
|
||||
"bonus_resource": "wood", # Wood for warmth
|
||||
"aggression": 0.4,
|
||||
"diplomacy_skill": 0.6,
|
||||
"trade_preference": 0.8,
|
||||
"color": "#4A90D9",
|
||||
},
|
||||
FactionType.RIVERFOLK: {
|
||||
"description": "Traders of the Rivers",
|
||||
"bonus_resource": "water",
|
||||
"aggression": 0.2,
|
||||
"diplomacy_skill": 0.9, # Best diplomats
|
||||
"trade_preference": 1.5, # Love trading
|
||||
"color": "#2E8B57",
|
||||
},
|
||||
FactionType.FORESTKIN: {
|
||||
"description": "Hunters of the Forest",
|
||||
"bonus_resource": "meat",
|
||||
"aggression": 0.5,
|
||||
"diplomacy_skill": 0.5,
|
||||
"trade_preference": 0.9,
|
||||
"color": "#228B22",
|
||||
},
|
||||
FactionType.MOUNTAINEER: {
|
||||
"description": "Miners of the Mountains",
|
||||
"bonus_resource": "oil", # Control oil fields
|
||||
"aggression": 0.3,
|
||||
"diplomacy_skill": 0.7,
|
||||
"trade_preference": 1.2,
|
||||
"color": "#8B4513",
|
||||
},
|
||||
FactionType.PLAINSMEN: {
|
||||
"description": "Farmers of the Plains",
|
||||
"bonus_resource": "berries",
|
||||
"aggression": 0.25,
|
||||
"diplomacy_skill": 0.6,
|
||||
"trade_preference": 1.0,
|
||||
"color": "#DAA520",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class DiplomaticStatus(Enum):
|
||||
"""Current diplomatic status between factions."""
|
||||
WAR = "war" # Active conflict
|
||||
HOSTILE = "hostile" # Near-war tensions
|
||||
COLD = "cold" # Cool relations
|
||||
NEUTRAL = "neutral" # Default state
|
||||
FRIENDLY = "friendly" # Good relations
|
||||
ALLIED = "allied" # Full alliance
|
||||
|
||||
|
||||
@dataclass
|
||||
class Treaty:
|
||||
"""A diplomatic treaty between factions."""
|
||||
faction1: FactionType
|
||||
faction2: FactionType
|
||||
treaty_type: str # "peace", "trade", "alliance"
|
||||
start_turn: int
|
||||
duration: int
|
||||
terms: dict = field(default_factory=dict)
|
||||
|
||||
def is_active(self, current_turn: int) -> bool:
|
||||
"""Check if treaty is still active."""
|
||||
if self.duration <= 0: # Permanent
|
||||
return True
|
||||
return current_turn < self.start_turn + self.duration
|
||||
|
||||
def turns_remaining(self, current_turn: int) -> int:
|
||||
"""Get turns remaining in treaty."""
|
||||
if self.duration <= 0:
|
||||
return -1 # Permanent
|
||||
return max(0, (self.start_turn + self.duration) - current_turn)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"faction1": self.faction1.value,
|
||||
"faction2": self.faction2.value,
|
||||
"treaty_type": self.treaty_type,
|
||||
"start_turn": self.start_turn,
|
||||
"duration": self.duration,
|
||||
"terms": self.terms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FactionRelations:
|
||||
"""Manages relations between all factions."""
|
||||
# Relations matrix (faction -> faction -> relation value 0-100)
|
||||
relations: Dict[FactionType, Dict[FactionType, int]] = field(default_factory=dict)
|
||||
|
||||
# Active wars
|
||||
active_wars: Set[tuple] = field(default_factory=set)
|
||||
|
||||
# Active treaties
|
||||
treaties: list = field(default_factory=list)
|
||||
|
||||
# War exhaustion per faction
|
||||
war_exhaustion: Dict[FactionType, int] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
self._initialize_relations()
|
||||
|
||||
def _initialize_relations(self) -> None:
|
||||
"""Initialize default relations between all factions."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
starting = config.diplomacy.starting_relations
|
||||
|
||||
for faction1 in FactionType:
|
||||
if faction1 not in self.relations:
|
||||
self.relations[faction1] = {}
|
||||
if faction1 not in self.war_exhaustion:
|
||||
self.war_exhaustion[faction1] = 0
|
||||
|
||||
for faction2 in FactionType:
|
||||
if faction2 not in self.relations[faction1]:
|
||||
if faction1 == faction2:
|
||||
self.relations[faction1][faction2] = 100 # Perfect self-relations
|
||||
else:
|
||||
self.relations[faction1][faction2] = starting
|
||||
|
||||
def get_relation(self, faction1: FactionType, faction2: FactionType) -> int:
|
||||
"""Get relation value between two factions (0-100)."""
|
||||
if faction1 not in self.relations:
|
||||
self._initialize_relations()
|
||||
return self.relations.get(faction1, {}).get(faction2, 50)
|
||||
|
||||
def get_status(self, faction1: FactionType, faction2: FactionType) -> DiplomaticStatus:
|
||||
"""Get diplomatic status between factions."""
|
||||
if faction1 == faction2:
|
||||
return DiplomaticStatus.ALLIED
|
||||
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Check for active war
|
||||
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||
if war_pair in self.active_wars:
|
||||
return DiplomaticStatus.WAR
|
||||
|
||||
relation = self.get_relation(faction1, faction2)
|
||||
|
||||
if relation >= config.diplomacy.alliance_threshold:
|
||||
return DiplomaticStatus.ALLIED
|
||||
elif relation >= 65:
|
||||
return DiplomaticStatus.FRIENDLY
|
||||
elif relation >= 40:
|
||||
return DiplomaticStatus.NEUTRAL
|
||||
elif relation >= config.diplomacy.war_threshold:
|
||||
return DiplomaticStatus.COLD
|
||||
else:
|
||||
return DiplomaticStatus.HOSTILE
|
||||
|
||||
def modify_relation(self, faction1: FactionType, faction2: FactionType, amount: int) -> int:
|
||||
"""Modify relation between factions (symmetric)."""
|
||||
if faction1 == faction2:
|
||||
return 100
|
||||
|
||||
if faction1 not in self.relations:
|
||||
self._initialize_relations()
|
||||
|
||||
# Modify symmetrically
|
||||
current1 = self.relations[faction1].get(faction2, 50)
|
||||
current2 = self.relations[faction2].get(faction1, 50)
|
||||
|
||||
new_value1 = max(0, min(100, current1 + amount))
|
||||
new_value2 = max(0, min(100, current2 + amount))
|
||||
|
||||
self.relations[faction1][faction2] = new_value1
|
||||
self.relations[faction2][faction1] = new_value2
|
||||
|
||||
return new_value1
|
||||
|
||||
def declare_war(self, aggressor: FactionType, defender: FactionType, turn: int) -> bool:
|
||||
"""Declare war between factions."""
|
||||
if aggressor == defender:
|
||||
return False
|
||||
if aggressor == FactionType.NEUTRAL or defender == FactionType.NEUTRAL:
|
||||
return False
|
||||
|
||||
war_pair = tuple(sorted([aggressor.value, defender.value]))
|
||||
if war_pair in self.active_wars:
|
||||
return False # Already at war
|
||||
|
||||
self.active_wars.add(war_pair)
|
||||
|
||||
# Relations plummet
|
||||
self.modify_relation(aggressor, defender, -50)
|
||||
|
||||
# Cancel any treaties
|
||||
self.treaties = [
|
||||
t for t in self.treaties
|
||||
if not (t.faction1 in (aggressor, defender) and t.faction2 in (aggressor, defender))
|
||||
]
|
||||
|
||||
return True
|
||||
|
||||
def make_peace(self, faction1: FactionType, faction2: FactionType, turn: int) -> bool:
|
||||
"""Make peace between warring factions."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||
if war_pair not in self.active_wars:
|
||||
return False
|
||||
|
||||
self.active_wars.remove(war_pair)
|
||||
|
||||
# Create peace treaty
|
||||
treaty = Treaty(
|
||||
faction1=faction1,
|
||||
faction2=faction2,
|
||||
treaty_type="peace",
|
||||
start_turn=turn,
|
||||
duration=config.diplomacy.peace_treaty_duration,
|
||||
)
|
||||
self.treaties.append(treaty)
|
||||
|
||||
# Improve relations slightly
|
||||
self.modify_relation(faction1, faction2, 15)
|
||||
|
||||
# Reset war exhaustion
|
||||
self.war_exhaustion[faction1] = 0
|
||||
self.war_exhaustion[faction2] = 0
|
||||
|
||||
return True
|
||||
|
||||
def form_alliance(self, faction1: FactionType, faction2: FactionType, turn: int) -> bool:
|
||||
"""Form an alliance between factions."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
if faction1 == faction2:
|
||||
return False
|
||||
|
||||
relation = self.get_relation(faction1, faction2)
|
||||
if relation < config.diplomacy.alliance_threshold:
|
||||
return False
|
||||
|
||||
# Check not already allied
|
||||
for treaty in self.treaties:
|
||||
if treaty.treaty_type == "alliance":
|
||||
if faction1 in (treaty.faction1, treaty.faction2) and faction2 in (treaty.faction1, treaty.faction2):
|
||||
return False
|
||||
|
||||
treaty = Treaty(
|
||||
faction1=faction1,
|
||||
faction2=faction2,
|
||||
treaty_type="alliance",
|
||||
start_turn=turn,
|
||||
duration=0, # Permanent until broken
|
||||
)
|
||||
self.treaties.append(treaty)
|
||||
|
||||
return True
|
||||
|
||||
def update_turn(self, current_turn: int) -> None:
|
||||
"""Update diplomacy state each turn."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Remove expired treaties
|
||||
self.treaties = [t for t in self.treaties if t.is_active(current_turn)]
|
||||
|
||||
# Relations naturally decay over time (things get worse without diplomacy)
|
||||
# This makes active diplomacy necessary to maintain peace
|
||||
for faction1 in FactionType:
|
||||
for faction2 in FactionType:
|
||||
if faction1 != faction2 and faction1 != FactionType.NEUTRAL and faction2 != FactionType.NEUTRAL:
|
||||
current = self.get_relation(faction1, faction2)
|
||||
# Relations decay down towards hostility
|
||||
# Only decay if above minimum (0) to avoid negative values
|
||||
if current > 0:
|
||||
self.relations[faction1][faction2] = max(0, current - config.diplomacy.relation_decay)
|
||||
|
||||
# Increase war exhaustion for factions at war
|
||||
for war_pair in self.active_wars:
|
||||
faction1_name, faction2_name = war_pair
|
||||
for faction in FactionType:
|
||||
if faction.value in (faction1_name, faction2_name):
|
||||
self.war_exhaustion[faction] = self.war_exhaustion.get(faction, 0) + config.diplomacy.war_exhaustion_rate
|
||||
|
||||
def is_at_war(self, faction1: FactionType, faction2: FactionType) -> bool:
|
||||
"""Check if two factions are at war."""
|
||||
if faction1 == faction2:
|
||||
return False
|
||||
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||
return war_pair in self.active_wars
|
||||
|
||||
def is_allied(self, faction1: FactionType, faction2: FactionType) -> bool:
|
||||
"""Check if two factions are allied."""
|
||||
if faction1 == faction2:
|
||||
return True
|
||||
for treaty in self.treaties:
|
||||
if treaty.treaty_type == "alliance":
|
||||
if faction1 in (treaty.faction1, treaty.faction2) and faction2 in (treaty.faction1, treaty.faction2):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_trade_modifier(self, faction1: FactionType, faction2: FactionType) -> float:
|
||||
"""Get trade modifier between factions based on relations."""
|
||||
if faction1 == faction2:
|
||||
return 1.2 # Same faction bonus
|
||||
|
||||
status = self.get_status(faction1, faction2)
|
||||
|
||||
modifiers = {
|
||||
DiplomaticStatus.WAR: 0.0, # No trade during war
|
||||
DiplomaticStatus.HOSTILE: 0.5,
|
||||
DiplomaticStatus.COLD: 0.8,
|
||||
DiplomaticStatus.NEUTRAL: 1.0,
|
||||
DiplomaticStatus.FRIENDLY: 1.1,
|
||||
DiplomaticStatus.ALLIED: 1.3,
|
||||
}
|
||||
|
||||
return modifiers.get(status, 1.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"relations": {
|
||||
f1.value: {f2.value: v for f2, v in inner.items()}
|
||||
for f1, inner in self.relations.items()
|
||||
},
|
||||
"active_wars": list(self.active_wars),
|
||||
"treaties": [t.to_dict() for t in self.treaties],
|
||||
"war_exhaustion": {f.value: e for f, e in self.war_exhaustion.items()},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentDiplomacy:
|
||||
"""An agent's diplomatic state and faction membership."""
|
||||
faction: FactionType = FactionType.NEUTRAL
|
||||
|
||||
# Personal relations with other agents (agent_id -> relation value)
|
||||
personal_relations: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# Diplomatic actions taken
|
||||
negotiations_conducted: int = 0
|
||||
wars_declared: int = 0
|
||||
peace_treaties_made: int = 0
|
||||
|
||||
@property
|
||||
def traits(self) -> dict:
|
||||
"""Get faction traits."""
|
||||
return FACTION_TRAITS.get(self.faction, FACTION_TRAITS[FactionType.NEUTRAL])
|
||||
|
||||
@property
|
||||
def diplomacy_skill(self) -> float:
|
||||
"""Get base diplomacy skill from faction."""
|
||||
return self.traits.get("diplomacy_skill", 0.5)
|
||||
|
||||
@property
|
||||
def aggression(self) -> float:
|
||||
"""Get faction aggression level."""
|
||||
return self.traits.get("aggression", 0.0)
|
||||
|
||||
@property
|
||||
def trade_preference(self) -> float:
|
||||
"""Get faction trade preference."""
|
||||
return self.traits.get("trade_preference", 1.0)
|
||||
|
||||
def get_personal_relation(self, other_id: str) -> int:
|
||||
"""Get personal relation with another agent."""
|
||||
return self.personal_relations.get(other_id, 50)
|
||||
|
||||
def modify_personal_relation(self, other_id: str, amount: int) -> int:
|
||||
"""Modify personal relation with another agent."""
|
||||
current = self.personal_relations.get(other_id, 50)
|
||||
new_value = max(0, min(100, current + amount))
|
||||
self.personal_relations[other_id] = new_value
|
||||
return new_value
|
||||
|
||||
def should_negotiate(self, other: "AgentDiplomacy", faction_relations: FactionRelations) -> bool:
|
||||
"""Check if agent should try to negotiate with another."""
|
||||
if self.faction == FactionType.NEUTRAL:
|
||||
return False
|
||||
|
||||
# Check if at war - high motivation to negotiate peace if exhausted
|
||||
if faction_relations.is_at_war(self.faction, other.faction):
|
||||
exhaustion = faction_relations.war_exhaustion.get(self.faction, 0)
|
||||
return exhaustion > 20 and random.random() < self.diplomacy_skill
|
||||
|
||||
# Try to improve relations if not allied
|
||||
if not faction_relations.is_allied(self.faction, other.faction):
|
||||
return random.random() < self.diplomacy_skill * 0.3
|
||||
|
||||
return False
|
||||
|
||||
def should_declare_war(self, other: "AgentDiplomacy", faction_relations: FactionRelations) -> bool:
|
||||
"""Check if agent should try to declare war."""
|
||||
if self.faction == FactionType.NEUTRAL or other.faction == FactionType.NEUTRAL:
|
||||
return False
|
||||
if self.faction == other.faction:
|
||||
return False
|
||||
if faction_relations.is_at_war(self.faction, other.faction):
|
||||
return False # Already at war
|
||||
|
||||
relation = faction_relations.get_relation(self.faction, other.faction)
|
||||
|
||||
# War is more likely with low relations and high aggression
|
||||
war_probability = (self.aggression * (1 - relation / 100)) * 0.2
|
||||
|
||||
return random.random() < war_probability
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"faction": self.faction.value,
|
||||
"faction_description": self.traits.get("description", ""),
|
||||
"faction_color": self.traits.get("color", "#808080"),
|
||||
"diplomacy_skill": self.diplomacy_skill,
|
||||
"aggression": self.aggression,
|
||||
"negotiations_conducted": self.negotiations_conducted,
|
||||
"wars_declared": self.wars_declared,
|
||||
"peace_treaties_made": self.peace_treaties_made,
|
||||
}
|
||||
|
||||
|
||||
def generate_random_faction(archetype: Optional[str] = None) -> AgentDiplomacy:
|
||||
"""Generate random faction membership for an agent."""
|
||||
factions = list(FactionType)
|
||||
weights = [1.0] * len(factions)
|
||||
|
||||
# Lower weight for neutral
|
||||
weights[factions.index(FactionType.NEUTRAL)] = 0.3
|
||||
|
||||
# Archetype influences faction choice
|
||||
if archetype == "hunter":
|
||||
weights[factions.index(FactionType.FORESTKIN)] = 3.0
|
||||
weights[factions.index(FactionType.MOUNTAINEER)] = 2.0
|
||||
elif archetype == "gatherer":
|
||||
weights[factions.index(FactionType.PLAINSMEN)] = 3.0
|
||||
weights[factions.index(FactionType.RIVERFOLK)] = 2.0
|
||||
elif archetype == "trader":
|
||||
weights[factions.index(FactionType.RIVERFOLK)] = 3.0
|
||||
elif archetype == "woodcutter":
|
||||
weights[factions.index(FactionType.NORTHLANDS)] = 3.0
|
||||
weights[factions.index(FactionType.FORESTKIN)] = 2.0
|
||||
|
||||
# Weighted random selection
|
||||
total = sum(weights)
|
||||
r = random.random() * total
|
||||
cumulative = 0
|
||||
chosen_faction = FactionType.NEUTRAL
|
||||
|
||||
for faction, weight in zip(factions, weights):
|
||||
cumulative += weight
|
||||
if r <= cumulative:
|
||||
chosen_faction = faction
|
||||
break
|
||||
|
||||
return AgentDiplomacy(faction=chosen_faction)
|
||||
|
||||
|
||||
# Global faction relations (shared across all agents)
|
||||
_global_faction_relations: Optional[FactionRelations] = None
|
||||
|
||||
|
||||
def get_faction_relations() -> FactionRelations:
|
||||
"""Get the global faction relations instance."""
|
||||
global _global_faction_relations
|
||||
if _global_faction_relations is None:
|
||||
_global_faction_relations = FactionRelations()
|
||||
return _global_faction_relations
|
||||
|
||||
|
||||
def reset_faction_relations() -> FactionRelations:
|
||||
"""Reset faction relations to default state."""
|
||||
global _global_faction_relations
|
||||
_global_faction_relations = FactionRelations()
|
||||
return _global_faction_relations
|
||||
|
||||
337
backend/domain/religion.py
Normal file
337
backend/domain/religion.py
Normal file
@ -0,0 +1,337 @@
|
||||
"""Religion system for the Village Simulation.
|
||||
|
||||
Creates diverse religious beliefs that affect agent behavior:
|
||||
- Each agent has a religion (or atheist)
|
||||
- Faith level affects actions and decisions
|
||||
- Same-religion agents cooperate better
|
||||
- Different religions can create conflict
|
||||
- High faith agents become zealots
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ReligionType(Enum):
|
||||
"""Types of religions in the simulation.
|
||||
|
||||
These represent different belief systems with unique characteristics.
|
||||
"""
|
||||
ATHEIST = "atheist" # No religion - neutral
|
||||
SOLARIS = "solaris" # Sun worshippers - value energy and activity
|
||||
AQUARIUS = "aquarius" # Water worshippers - value water and peace
|
||||
TERRANUS = "terranus" # Earth worshippers - value resources and hoarding
|
||||
IGNIS = "ignis" # Fire worshippers - value heat and trade
|
||||
NATURIS = "naturis" # Nature worshippers - value gathering and sustainability
|
||||
|
||||
|
||||
# Religion characteristics
|
||||
RELIGION_TRAITS = {
|
||||
ReligionType.ATHEIST: {
|
||||
"description": "No religious belief",
|
||||
"bonus_action": None,
|
||||
"preferred_resource": None,
|
||||
"aggression": 0.0,
|
||||
"conversion_resistance": 0.3,
|
||||
},
|
||||
ReligionType.SOLARIS: {
|
||||
"description": "Worshippers of the Sun",
|
||||
"bonus_action": "hunt", # Sun gives strength to hunt
|
||||
"preferred_resource": "meat",
|
||||
"aggression": 0.4, # Moderate aggression
|
||||
"conversion_resistance": 0.6,
|
||||
},
|
||||
ReligionType.AQUARIUS: {
|
||||
"description": "Worshippers of Water",
|
||||
"bonus_action": "get_water",
|
||||
"preferred_resource": "water",
|
||||
"aggression": 0.1, # Peaceful religion
|
||||
"conversion_resistance": 0.7,
|
||||
},
|
||||
ReligionType.TERRANUS: {
|
||||
"description": "Worshippers of the Earth",
|
||||
"bonus_action": "gather",
|
||||
"preferred_resource": "berries",
|
||||
"aggression": 0.2,
|
||||
"conversion_resistance": 0.8,
|
||||
},
|
||||
ReligionType.IGNIS: {
|
||||
"description": "Worshippers of Fire",
|
||||
"bonus_action": "trade", # Fire of commerce
|
||||
"preferred_resource": "wood",
|
||||
"aggression": 0.5, # Hot-tempered
|
||||
"conversion_resistance": 0.5,
|
||||
},
|
||||
ReligionType.NATURIS: {
|
||||
"description": "Worshippers of Nature",
|
||||
"bonus_action": "gather",
|
||||
"preferred_resource": "berries",
|
||||
"aggression": 0.15, # Peaceful
|
||||
"conversion_resistance": 0.75,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReligiousBeliefs:
|
||||
"""An agent's religious beliefs and faith state."""
|
||||
religion: ReligionType = ReligionType.ATHEIST
|
||||
faith: int = 50 # 0-100, loaded from config
|
||||
|
||||
# Historical conversion tracking
|
||||
times_converted: int = 0
|
||||
last_prayer_turn: int = -1
|
||||
|
||||
# Zealot state
|
||||
is_zealot: bool = False
|
||||
|
||||
# Religious influence
|
||||
converts_made: int = 0
|
||||
sermons_given: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self._update_zealot_status()
|
||||
|
||||
def _update_zealot_status(self) -> None:
|
||||
"""Update zealot status based on faith level."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
threshold = int(config.religion.zealot_threshold * 100)
|
||||
self.is_zealot = self.faith >= threshold
|
||||
|
||||
@property
|
||||
def traits(self) -> dict:
|
||||
"""Get traits for current religion."""
|
||||
return RELIGION_TRAITS.get(self.religion, RELIGION_TRAITS[ReligionType.ATHEIST])
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Get religion description."""
|
||||
return self.traits["description"]
|
||||
|
||||
@property
|
||||
def is_religious(self) -> bool:
|
||||
"""Check if agent has a religion."""
|
||||
return self.religion != ReligionType.ATHEIST
|
||||
|
||||
@property
|
||||
def conversion_resistance(self) -> float:
|
||||
"""Get resistance to conversion."""
|
||||
base = self.traits.get("conversion_resistance", 0.5)
|
||||
# Higher faith = harder to convert
|
||||
faith_modifier = self.faith / 100 * 0.3
|
||||
return min(0.95, base + faith_modifier)
|
||||
|
||||
def gain_faith(self, amount: int) -> None:
|
||||
"""Increase faith level."""
|
||||
self.faith = min(100, self.faith + amount)
|
||||
self._update_zealot_status()
|
||||
|
||||
def lose_faith(self, amount: int) -> None:
|
||||
"""Decrease faith level."""
|
||||
self.faith = max(0, self.faith - amount)
|
||||
self._update_zealot_status()
|
||||
|
||||
def apply_decay(self) -> None:
|
||||
"""Apply faith decay per turn (if not recently prayed)."""
|
||||
from backend.config import get_config
|
||||
decay = get_config().agent_stats.faith_decay
|
||||
self.faith = max(0, self.faith - decay)
|
||||
self._update_zealot_status()
|
||||
|
||||
def convert_to(self, new_religion: ReligionType, faith_level: int = 30) -> bool:
|
||||
"""Attempt to convert to a new religion."""
|
||||
if new_religion == self.religion:
|
||||
return False
|
||||
|
||||
# Check conversion resistance
|
||||
if random.random() < self.conversion_resistance:
|
||||
return False
|
||||
|
||||
self.religion = new_religion
|
||||
self.faith = faith_level
|
||||
self.times_converted += 1
|
||||
self._update_zealot_status()
|
||||
return True
|
||||
|
||||
def record_prayer(self, turn: int) -> None:
|
||||
"""Record that prayer was performed."""
|
||||
self.last_prayer_turn = turn
|
||||
|
||||
def record_conversion(self) -> None:
|
||||
"""Record a successful conversion made."""
|
||||
self.converts_made += 1
|
||||
|
||||
def record_sermon(self) -> None:
|
||||
"""Record a sermon given."""
|
||||
self.sermons_given += 1
|
||||
|
||||
def get_trade_modifier(self, other: "ReligiousBeliefs") -> float:
|
||||
"""Get trade modifier when dealing with another agent's religion."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
if self.religion == ReligionType.ATHEIST or other.religion == ReligionType.ATHEIST:
|
||||
return 1.0 # No modifier for atheists
|
||||
|
||||
if self.religion == other.religion:
|
||||
# Same religion bonus
|
||||
bonus = config.religion.same_religion_bonus
|
||||
# Zealots get extra bonus
|
||||
if self.is_zealot and other.is_zealot:
|
||||
bonus *= 1.5
|
||||
return 1.0 + bonus
|
||||
else:
|
||||
# Different religion penalty
|
||||
penalty = config.religion.different_religion_penalty
|
||||
# Zealots are more hostile to other religions
|
||||
if self.is_zealot:
|
||||
penalty *= 1.5
|
||||
return 1.0 - penalty
|
||||
|
||||
def should_convert_other(self, other: "ReligiousBeliefs") -> bool:
|
||||
"""Check if agent should try to convert another agent."""
|
||||
if not self.is_religious:
|
||||
return False
|
||||
if self.religion == other.religion:
|
||||
return False
|
||||
# Zealots always want to convert
|
||||
if self.is_zealot:
|
||||
return True
|
||||
# High faith agents sometimes want to convert
|
||||
return random.random() < (self.faith / 100) * 0.5
|
||||
|
||||
def is_hostile_to(self, other: "ReligiousBeliefs") -> bool:
|
||||
"""Check if religiously hostile to another agent."""
|
||||
if not self.is_religious or not other.is_religious:
|
||||
return False
|
||||
if self.religion == other.religion:
|
||||
return False
|
||||
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Only zealots are hostile
|
||||
if not self.is_zealot:
|
||||
return False
|
||||
|
||||
# Check if faith is high enough for holy war
|
||||
if self.faith >= config.religion.holy_war_threshold * 100:
|
||||
return True
|
||||
|
||||
return random.random() < self.traits.get("aggression", 0.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"religion": self.religion.value,
|
||||
"faith": self.faith,
|
||||
"is_zealot": self.is_zealot,
|
||||
"times_converted": self.times_converted,
|
||||
"converts_made": self.converts_made,
|
||||
"description": self.description,
|
||||
}
|
||||
|
||||
|
||||
def generate_random_religion(archetype: Optional[str] = None) -> ReligiousBeliefs:
|
||||
"""Generate random religious beliefs for an agent.
|
||||
|
||||
Args:
|
||||
archetype: Optional personality archetype that influences religion
|
||||
"""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Get available religions
|
||||
religions = list(ReligionType)
|
||||
|
||||
# Weight by archetype
|
||||
weights = [1.0] * len(religions)
|
||||
|
||||
if archetype == "hunter":
|
||||
# Hunters favor Solaris (sun/strength)
|
||||
weights[religions.index(ReligionType.SOLARIS)] = 3.0
|
||||
weights[religions.index(ReligionType.IGNIS)] = 2.0
|
||||
elif archetype == "gatherer":
|
||||
# Gatherers favor Naturis/Terranus
|
||||
weights[religions.index(ReligionType.NATURIS)] = 3.0
|
||||
weights[religions.index(ReligionType.TERRANUS)] = 2.0
|
||||
elif archetype == "trader":
|
||||
# Traders favor Ignis (commerce)
|
||||
weights[religions.index(ReligionType.IGNIS)] = 3.0
|
||||
weights[religions.index(ReligionType.AQUARIUS)] = 2.0 # Water trade routes
|
||||
elif archetype == "woodcutter":
|
||||
weights[religions.index(ReligionType.TERRANUS)] = 2.0
|
||||
weights[religions.index(ReligionType.NATURIS)] = 1.5
|
||||
|
||||
# Atheists are uncommon - lower base weight
|
||||
weights[religions.index(ReligionType.ATHEIST)] = 0.2
|
||||
|
||||
# Weighted random selection
|
||||
total = sum(weights)
|
||||
r = random.random() * total
|
||||
cumulative = 0
|
||||
chosen_religion = ReligionType.ATHEIST
|
||||
|
||||
for i, (religion, weight) in enumerate(zip(religions, weights)):
|
||||
cumulative += weight
|
||||
if r <= cumulative:
|
||||
chosen_religion = religion
|
||||
break
|
||||
|
||||
# Starting faith varies
|
||||
if chosen_religion == ReligionType.ATHEIST:
|
||||
starting_faith = random.randint(0, 20)
|
||||
else:
|
||||
starting_faith = random.randint(30, 70)
|
||||
|
||||
return ReligiousBeliefs(
|
||||
religion=chosen_religion,
|
||||
faith=starting_faith,
|
||||
)
|
||||
|
||||
|
||||
def get_religion_compatibility(religion1: ReligionType, religion2: ReligionType) -> float:
|
||||
"""Get compatibility score between two religions (0-1)."""
|
||||
if religion1 == religion2:
|
||||
return 1.0
|
||||
|
||||
if religion1 == ReligionType.ATHEIST or religion2 == ReligionType.ATHEIST:
|
||||
return 0.7 # Atheists are neutral
|
||||
|
||||
# Compatible pairs
|
||||
compatible_pairs = [
|
||||
(ReligionType.NATURIS, ReligionType.AQUARIUS), # Nature and water
|
||||
(ReligionType.TERRANUS, ReligionType.NATURIS), # Earth and nature
|
||||
(ReligionType.SOLARIS, ReligionType.IGNIS), # Sun and fire
|
||||
]
|
||||
|
||||
# Hostile pairs
|
||||
hostile_pairs = [
|
||||
(ReligionType.AQUARIUS, ReligionType.IGNIS), # Water vs fire
|
||||
(ReligionType.SOLARIS, ReligionType.AQUARIUS), # Sun vs water
|
||||
]
|
||||
|
||||
pair = (religion1, religion2)
|
||||
reverse_pair = (religion2, religion1)
|
||||
|
||||
if pair in compatible_pairs or reverse_pair in compatible_pairs:
|
||||
return 0.8
|
||||
if pair in hostile_pairs or reverse_pair in hostile_pairs:
|
||||
return 0.3
|
||||
|
||||
return 0.5 # Neutral
|
||||
|
||||
|
||||
def get_religion_action_bonus(religion: ReligionType, action_type: str) -> float:
|
||||
"""Get action bonus/penalty for a religion performing an action."""
|
||||
traits = RELIGION_TRAITS.get(religion, {})
|
||||
bonus_action = traits.get("bonus_action")
|
||||
|
||||
if bonus_action == action_type:
|
||||
return 1.15 # 15% bonus for favored action
|
||||
|
||||
return 1.0
|
||||
|
||||
@ -19,6 +19,9 @@ class ResourceType(Enum):
|
||||
WOOD = "wood"
|
||||
HIDE = "hide"
|
||||
CLOTHES = "clothes"
|
||||
# NEW: Oil industry resources
|
||||
OIL = "oil" # Raw crude oil - must be refined
|
||||
FUEL = "fuel" # Refined fuel - provides heat and energy
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -32,7 +35,6 @@ class ResourceEffect:
|
||||
|
||||
def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
||||
"""Get resource effects from the global config."""
|
||||
# Import here to avoid circular imports
|
||||
from backend.config import get_config
|
||||
|
||||
config = get_config()
|
||||
@ -53,12 +55,19 @@ def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
||||
ResourceType.WOOD: ResourceEffect(), # Used as fuel, not consumed directly
|
||||
ResourceType.HIDE: ResourceEffect(), # Used for crafting
|
||||
ResourceType.CLOTHES: ResourceEffect(), # Passive heat reduction effect
|
||||
# NEW: Oil resources
|
||||
ResourceType.OIL: ResourceEffect(
|
||||
energy=resources.oil_energy, # Raw oil has no direct use
|
||||
),
|
||||
ResourceType.FUEL: ResourceEffect(
|
||||
energy=resources.fuel_energy, # Refined fuel provides energy
|
||||
heat=resources.fuel_heat, # And significant heat
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
||||
"""Get resource decay rates from the global config."""
|
||||
# Import here to avoid circular imports
|
||||
from backend.config import get_config
|
||||
|
||||
config = get_config()
|
||||
@ -71,6 +80,9 @@ def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
||||
ResourceType.WOOD: None, # Infinite
|
||||
ResourceType.HIDE: None, # Infinite
|
||||
ResourceType.CLOTHES: resources.clothes_decay if resources.clothes_decay > 0 else None,
|
||||
# NEW: Oil resources don't decay
|
||||
ResourceType.OIL: resources.oil_decay if resources.oil_decay > 0 else None,
|
||||
ResourceType.FUEL: resources.fuel_decay if resources.fuel_decay > 0 else None,
|
||||
}
|
||||
|
||||
|
||||
@ -80,6 +92,12 @@ def get_fire_heat() -> int:
|
||||
return get_config().resources.fire_heat
|
||||
|
||||
|
||||
def get_fuel_heat() -> int:
|
||||
"""Get heat provided by burning fuel."""
|
||||
from backend.config import get_config
|
||||
return get_config().resources.fuel_heat
|
||||
|
||||
|
||||
# Cached values for performance
|
||||
_resource_effects_cache: Optional[dict[ResourceType, ResourceEffect]] = None
|
||||
_resource_decay_cache: Optional[dict[ResourceType, Optional[int]]] = None
|
||||
@ -140,6 +158,37 @@ RESOURCE_EFFECTS = _ResourceEffectsAccessor()
|
||||
RESOURCE_DECAY_RATES = _ResourceDecayAccessor()
|
||||
|
||||
|
||||
# Resource categories for AI and display
|
||||
FOOD_RESOURCES = {ResourceType.MEAT, ResourceType.BERRIES}
|
||||
DRINK_RESOURCES = {ResourceType.WATER}
|
||||
HEAT_RESOURCES = {ResourceType.WOOD, ResourceType.FUEL}
|
||||
CRAFTING_MATERIALS = {ResourceType.HIDE, ResourceType.OIL}
|
||||
VALUABLE_RESOURCES = {ResourceType.OIL, ResourceType.FUEL, ResourceType.CLOTHES}
|
||||
|
||||
|
||||
def get_resource_base_value(resource_type: ResourceType) -> int:
|
||||
"""Get the base economic value of a resource."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Oil and fuel have special pricing
|
||||
if resource_type == ResourceType.OIL:
|
||||
return config.economy.oil_base_price
|
||||
elif resource_type == ResourceType.FUEL:
|
||||
return config.economy.fuel_base_price
|
||||
|
||||
# Other resources based on production cost
|
||||
base_values = {
|
||||
ResourceType.MEAT: 15,
|
||||
ResourceType.BERRIES: 5,
|
||||
ResourceType.WATER: 3,
|
||||
ResourceType.WOOD: 8,
|
||||
ResourceType.HIDE: 10,
|
||||
ResourceType.CLOTHES: 20,
|
||||
}
|
||||
return base_values.get(resource_type, 10)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Resource:
|
||||
"""A resource instance in the simulation."""
|
||||
@ -157,6 +206,16 @@ class Resource:
|
||||
"""Get the effect of consuming this resource."""
|
||||
return get_cached_resource_effects()[self.type]
|
||||
|
||||
@property
|
||||
def is_valuable(self) -> bool:
|
||||
"""Check if this is a high-value resource."""
|
||||
return self.type in VALUABLE_RESOURCES
|
||||
|
||||
@property
|
||||
def base_value(self) -> int:
|
||||
"""Get the base economic value."""
|
||||
return get_resource_base_value(self.type)
|
||||
|
||||
def is_expired(self, current_turn: int) -> bool:
|
||||
"""Check if the resource has decayed."""
|
||||
if self.decay_rate is None:
|
||||
@ -177,4 +236,5 @@ class Resource:
|
||||
"quantity": self.quantity,
|
||||
"created_turn": self.created_turn,
|
||||
"decay_rate": self.decay_rate,
|
||||
"base_value": self.base_value,
|
||||
}
|
||||
|
||||
137
config.json
137
config.json
@ -1,73 +1,118 @@
|
||||
{
|
||||
"agent_stats": {
|
||||
"max_energy": 50,
|
||||
"max_energy": 60,
|
||||
"max_hunger": 100,
|
||||
"max_thirst": 100,
|
||||
"max_heat": 100,
|
||||
"start_energy": 50,
|
||||
"start_hunger": 70,
|
||||
"start_thirst": 75,
|
||||
"max_faith": 100,
|
||||
"start_energy": 60,
|
||||
"start_hunger": 90,
|
||||
"start_thirst": 90,
|
||||
"start_heat": 100,
|
||||
"start_faith": 45,
|
||||
"energy_decay": 1,
|
||||
"hunger_decay": 2,
|
||||
"thirst_decay": 3,
|
||||
"heat_decay": 3,
|
||||
"critical_threshold": 0.25,
|
||||
"hunger_decay": 1,
|
||||
"thirst_decay": 2,
|
||||
"heat_decay": 2,
|
||||
"faith_decay": 1,
|
||||
"critical_threshold": 0.18,
|
||||
"low_energy_threshold": 12
|
||||
},
|
||||
"resources": {
|
||||
"meat_decay": 10,
|
||||
"berries_decay": 6,
|
||||
"clothes_decay": 20,
|
||||
"meat_hunger": 35,
|
||||
"meat_energy": 12,
|
||||
"berries_hunger": 10,
|
||||
"berries_thirst": 4,
|
||||
"water_thirst": 50,
|
||||
"fire_heat": 20
|
||||
"meat_decay": 15,
|
||||
"berries_decay": 10,
|
||||
"clothes_decay": 30,
|
||||
"oil_decay": 0,
|
||||
"fuel_decay": 0,
|
||||
"meat_hunger": 45,
|
||||
"meat_energy": 15,
|
||||
"berries_hunger": 15,
|
||||
"berries_thirst": 6,
|
||||
"water_thirst": 60,
|
||||
"fire_heat": 30,
|
||||
"fuel_heat": 45,
|
||||
"oil_energy": 0,
|
||||
"fuel_energy": 12
|
||||
},
|
||||
"actions": {
|
||||
"sleep_energy": 55,
|
||||
"rest_energy": 12,
|
||||
"hunt_energy": -7,
|
||||
"gather_energy": -3,
|
||||
"chop_wood_energy": -6,
|
||||
"rest_energy": 15,
|
||||
"hunt_energy": -5,
|
||||
"gather_energy": -2,
|
||||
"chop_wood_energy": -4,
|
||||
"get_water_energy": -2,
|
||||
"weave_energy": -6,
|
||||
"build_fire_energy": -4,
|
||||
"weave_energy": -4,
|
||||
"build_fire_energy": -3,
|
||||
"trade_energy": -1,
|
||||
"hunt_success": 0.70,
|
||||
"drill_oil_energy": -7,
|
||||
"refine_energy": -5,
|
||||
"pray_energy": -2,
|
||||
"preach_energy": -3,
|
||||
"negotiate_energy": -2,
|
||||
"declare_war_energy": -3,
|
||||
"make_peace_energy": -2,
|
||||
"hunt_success": 0.80,
|
||||
"chop_wood_success": 0.90,
|
||||
"hunt_meat_min": 2,
|
||||
"hunt_meat_max": 5,
|
||||
"drill_oil_success": 0.70,
|
||||
"hunt_meat_min": 3,
|
||||
"hunt_meat_max": 6,
|
||||
"hunt_hide_min": 0,
|
||||
"hunt_hide_max": 2,
|
||||
"gather_min": 2,
|
||||
"gather_max": 4,
|
||||
"chop_wood_min": 1,
|
||||
"chop_wood_max": 3
|
||||
"gather_min": 3,
|
||||
"gather_max": 6,
|
||||
"chop_wood_min": 2,
|
||||
"chop_wood_max": 4,
|
||||
"drill_oil_min": 2,
|
||||
"drill_oil_max": 5,
|
||||
"pray_faith_gain": 18,
|
||||
"preach_faith_spread": 10,
|
||||
"preach_convert_chance": 0.10
|
||||
},
|
||||
"world": {
|
||||
"width": 25,
|
||||
"height": 25,
|
||||
"initial_agents": 25,
|
||||
"width": 50,
|
||||
"height": 50,
|
||||
"initial_agents": 100,
|
||||
"day_steps": 10,
|
||||
"night_steps": 1,
|
||||
"inventory_slots": 12,
|
||||
"starting_money": 80
|
||||
"inventory_slots": 14,
|
||||
"starting_money": 100,
|
||||
"oil_fields_count": 5,
|
||||
"temple_count": 5
|
||||
},
|
||||
"market": {
|
||||
"turns_before_discount": 15,
|
||||
"discount_rate": 0.12,
|
||||
"base_price_multiplier": 1.3
|
||||
"turns_before_discount": 10,
|
||||
"discount_rate": 0.08,
|
||||
"base_price_multiplier": 1.15
|
||||
},
|
||||
"economy": {
|
||||
"energy_to_money_ratio": 1.5,
|
||||
"wealth_desire": 0.35,
|
||||
"buy_efficiency_threshold": 0.75,
|
||||
"min_wealth_target": 50,
|
||||
"max_price_markup": 2.5,
|
||||
"min_price_discount": 0.4
|
||||
"energy_to_money_ratio": 1.2,
|
||||
"wealth_desire": 0.25,
|
||||
"buy_efficiency_threshold": 0.85,
|
||||
"min_wealth_target": 30,
|
||||
"max_price_markup": 1.8,
|
||||
"min_price_discount": 0.5,
|
||||
"oil_base_price": 18,
|
||||
"fuel_base_price": 30
|
||||
},
|
||||
"auto_step_interval": 0.15
|
||||
}
|
||||
"religion": {
|
||||
"num_religions": 5,
|
||||
"conversion_resistance": 0.55,
|
||||
"zealot_threshold": 0.75,
|
||||
"faith_trade_bonus": 0.10,
|
||||
"same_religion_bonus": 0.12,
|
||||
"different_religion_penalty": 0.06,
|
||||
"holy_war_threshold": 0.85
|
||||
},
|
||||
"diplomacy": {
|
||||
"num_factions": 5,
|
||||
"starting_relations": 50,
|
||||
"alliance_threshold": 75,
|
||||
"war_threshold": 20,
|
||||
"relation_decay": 2,
|
||||
"trade_relation_boost": 5,
|
||||
"war_damage_multiplier": 1.2,
|
||||
"peace_treaty_duration": 15,
|
||||
"war_exhaustion_rate": 5
|
||||
},
|
||||
"auto_step_interval": 0.10
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
"""HTTP client for communicating with the Village Simulation backend."""
|
||||
"""HTTP client for communicating with the Village Simulation backend.
|
||||
|
||||
Handles state including religion, factions, diplomacy, and oil economy.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
|
||||
import requests
|
||||
@ -25,6 +28,15 @@ class SimulationState:
|
||||
is_running: bool
|
||||
recent_logs: list[dict]
|
||||
|
||||
# New fields for religion, factions, diplomacy
|
||||
oil_fields: list[dict] = field(default_factory=list)
|
||||
temples: list[dict] = field(default_factory=list)
|
||||
faction_relations: dict = field(default_factory=dict)
|
||||
diplomatic_events: list[dict] = field(default_factory=list)
|
||||
religious_events: list[dict] = field(default_factory=list)
|
||||
active_wars: list[dict] = field(default_factory=list)
|
||||
peace_treaties: list[dict] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict) -> "SimulationState":
|
||||
"""Create from API response data."""
|
||||
@ -42,11 +54,74 @@ 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:
|
||||
@ -82,7 +157,7 @@ class SimulationClient:
|
||||
self.connected = True
|
||||
self._retry_count = 0
|
||||
return response.json()
|
||||
except RequestException as e:
|
||||
except RequestException:
|
||||
self._retry_count += 1
|
||||
if self._retry_count >= self._max_retries:
|
||||
self.connected = False
|
||||
@ -107,7 +182,7 @@ class SimulationClient:
|
||||
if data:
|
||||
self.last_state = SimulationState.from_api_response(data)
|
||||
return self.last_state
|
||||
return self.last_state # Return cached state if request failed
|
||||
return self.last_state
|
||||
|
||||
def advance_turn(self) -> bool:
|
||||
"""Advance the simulation by one step."""
|
||||
@ -121,9 +196,9 @@ class SimulationClient:
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
num_agents: int = 8,
|
||||
world_width: int = 20,
|
||||
world_height: int = 20,
|
||||
num_agents: int = 100,
|
||||
world_width: int = 30,
|
||||
world_height: int = 30,
|
||||
) -> bool:
|
||||
"""Initialize or reset the simulation."""
|
||||
result = self._request("POST", "/control/initialize", json={
|
||||
@ -177,4 +252,3 @@ class SimulationClient:
|
||||
"""Reset configuration to defaults."""
|
||||
result = self._request("POST", "/config/reset")
|
||||
return result is not None and result.get("success", False)
|
||||
|
||||
|
||||
122
frontend/main.py
122
frontend/main.py
@ -1,4 +1,7 @@
|
||||
"""Main Pygame application for the Village Simulation frontend."""
|
||||
"""Main Pygame application for the Village Simulation frontend.
|
||||
|
||||
Redesigned for fullscreen, 100+ agents, with religion, factions, and diplomacy.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pygame
|
||||
@ -12,14 +15,13 @@ from frontend.renderer.stats_renderer import StatsRenderer
|
||||
|
||||
|
||||
# Window configuration
|
||||
WINDOW_WIDTH = 1200
|
||||
WINDOW_HEIGHT = 800
|
||||
WINDOW_TITLE = "Village Economy Simulation"
|
||||
FPS = 30
|
||||
WINDOW_TITLE = "Village Simulation - Economy, Religion & Diplomacy"
|
||||
FPS = 60
|
||||
|
||||
# Layout configuration
|
||||
TOP_PANEL_HEIGHT = 50
|
||||
RIGHT_PANEL_WIDTH = 200
|
||||
# Layout ratios (will scale with screen)
|
||||
TOP_PANEL_HEIGHT_RATIO = 0.06
|
||||
RIGHT_PANEL_WIDTH_RATIO = 0.22
|
||||
BOTTOM_PANEL_HEIGHT_RATIO = 0.08
|
||||
|
||||
|
||||
class VillageSimulationApp:
|
||||
@ -30,31 +32,60 @@ class VillageSimulationApp:
|
||||
pygame.init()
|
||||
pygame.font.init()
|
||||
|
||||
# Create window
|
||||
self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
|
||||
# Get display info for fullscreen
|
||||
display_info = pygame.display.Info()
|
||||
self.screen_width = display_info.current_w
|
||||
self.screen_height = display_info.current_h
|
||||
|
||||
# Create fullscreen window
|
||||
self.screen = pygame.display.set_mode(
|
||||
(self.screen_width, self.screen_height),
|
||||
pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE
|
||||
)
|
||||
pygame.display.set_caption(WINDOW_TITLE)
|
||||
|
||||
# Hide mouse cursor briefly on startup
|
||||
pygame.mouse.set_visible(True)
|
||||
|
||||
# Clock for FPS control
|
||||
self.clock = pygame.time.Clock()
|
||||
|
||||
# Fonts
|
||||
self.font = pygame.font.Font(None, 24)
|
||||
# Calculate layout dimensions
|
||||
self.top_panel_height = int(self.screen_height * TOP_PANEL_HEIGHT_RATIO)
|
||||
self.right_panel_width = int(self.screen_width * RIGHT_PANEL_WIDTH_RATIO)
|
||||
self.bottom_panel_height = int(self.screen_height * BOTTOM_PANEL_HEIGHT_RATIO)
|
||||
|
||||
# Fonts - scale with screen
|
||||
font_scale = min(self.screen_width / 1920, self.screen_height / 1080)
|
||||
self.font_size_small = max(14, int(16 * font_scale))
|
||||
self.font_size_medium = max(18, int(22 * font_scale))
|
||||
self.font_size_large = max(24, int(28 * font_scale))
|
||||
|
||||
self.font = pygame.font.Font(None, self.font_size_medium)
|
||||
self.font_small = pygame.font.Font(None, self.font_size_small)
|
||||
self.font_large = pygame.font.Font(None, self.font_size_large)
|
||||
|
||||
# Network client
|
||||
self.client = SimulationClient(server_url)
|
||||
|
||||
# Calculate map area
|
||||
# Calculate map area (left side, between top and bottom panels)
|
||||
self.map_rect = pygame.Rect(
|
||||
0,
|
||||
TOP_PANEL_HEIGHT,
|
||||
WINDOW_WIDTH - RIGHT_PANEL_WIDTH,
|
||||
WINDOW_HEIGHT - TOP_PANEL_HEIGHT,
|
||||
self.top_panel_height,
|
||||
self.screen_width - self.right_panel_width,
|
||||
self.screen_height - self.top_panel_height - self.bottom_panel_height,
|
||||
)
|
||||
|
||||
# Initialize renderers
|
||||
# Initialize renderers with screen dimensions
|
||||
self.map_renderer = MapRenderer(self.screen, self.map_rect)
|
||||
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font)
|
||||
self.ui_renderer = UIRenderer(self.screen, self.font)
|
||||
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font_small)
|
||||
self.ui_renderer = UIRenderer(
|
||||
self.screen,
|
||||
self.font,
|
||||
self.top_panel_height,
|
||||
self.right_panel_width,
|
||||
self.bottom_panel_height
|
||||
)
|
||||
self.settings_renderer = SettingsRenderer(self.screen)
|
||||
self.stats_renderer = StatsRenderer(self.screen)
|
||||
|
||||
@ -62,26 +93,25 @@ class VillageSimulationApp:
|
||||
self.state: SimulationState | None = None
|
||||
self.running = True
|
||||
self.hovered_agent: dict | None = None
|
||||
self._last_turn: int = -1 # Track turn changes for stats update
|
||||
self._last_turn: int = -1
|
||||
|
||||
# Polling interval (ms)
|
||||
self.last_poll_time = 0
|
||||
self.poll_interval = 100 # Poll every 100ms for smoother updates
|
||||
self.poll_interval = 50 # Poll every 50ms for smoother updates
|
||||
|
||||
# Setup settings callbacks
|
||||
self._setup_settings_callbacks()
|
||||
|
||||
def _setup_settings_callbacks(self) -> None:
|
||||
"""Set up callbacks for the settings panel."""
|
||||
# Override the apply and reset callbacks
|
||||
original_apply = self.settings_renderer._apply_config
|
||||
original_reset = self.settings_renderer._reset_config
|
||||
|
||||
def apply_config():
|
||||
config = self.settings_renderer.get_config()
|
||||
if self.client.update_config(config):
|
||||
# Restart simulation with new config
|
||||
if self.client.initialize():
|
||||
num_agents = config.get("world", {}).get("initial_agents", 100)
|
||||
if self.client.initialize(num_agents=num_agents):
|
||||
self.state = self.client.get_state()
|
||||
self.settings_renderer.status_message = "Config applied & simulation restarted!"
|
||||
self.settings_renderer.status_color = (80, 180, 100)
|
||||
@ -94,7 +124,6 @@ class VillageSimulationApp:
|
||||
|
||||
def reset_config():
|
||||
if self.client.reset_config():
|
||||
# Reload config from server
|
||||
config = self.client.get_config()
|
||||
if config:
|
||||
self.settings_renderer.set_config(config)
|
||||
@ -147,13 +176,12 @@ class VillageSimulationApp:
|
||||
# Advance one turn
|
||||
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||
if self.client.advance_turn():
|
||||
# Immediately fetch new state
|
||||
self.state = self.client.get_state()
|
||||
|
||||
elif event.key == pygame.K_r:
|
||||
# Reset simulation
|
||||
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||
if self.client.initialize():
|
||||
if self.client.initialize(num_agents=100):
|
||||
self.state = self.client.get_state()
|
||||
self.stats_renderer.clear_history()
|
||||
self._last_turn = -1
|
||||
@ -176,6 +204,10 @@ class VillageSimulationApp:
|
||||
if not self.settings_renderer.visible:
|
||||
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."""
|
||||
@ -190,7 +222,7 @@ class VillageSimulationApp:
|
||||
if not self.map_rect.collidepoint(mouse_pos):
|
||||
return
|
||||
|
||||
# Check each agent
|
||||
# Check each agent (only check visible ones for performance)
|
||||
for agent in self.state.agents:
|
||||
if not agent.get("is_alive", False):
|
||||
continue
|
||||
@ -242,8 +274,8 @@ class VillageSimulationApp:
|
||||
|
||||
def draw(self) -> None:
|
||||
"""Draw all elements."""
|
||||
# Clear screen
|
||||
self.screen.fill((30, 35, 45))
|
||||
# Clear screen with dark background
|
||||
self.screen.fill((15, 17, 23))
|
||||
|
||||
if self.state:
|
||||
# Draw map
|
||||
@ -252,7 +284,7 @@ class VillageSimulationApp:
|
||||
# Draw agents
|
||||
self.agent_renderer.draw(self.state)
|
||||
|
||||
# Draw UI
|
||||
# Draw UI panels
|
||||
self.ui_renderer.draw(self.state)
|
||||
|
||||
# Draw agent tooltip if hovering
|
||||
@ -272,16 +304,22 @@ class VillageSimulationApp:
|
||||
|
||||
# Draw hints at bottom
|
||||
if not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||
hint_font = pygame.font.Font(None, 18)
|
||||
hint = hint_font.render("S: Settings | G: Statistics & Graphs", True, (100, 100, 120))
|
||||
self.screen.blit(hint, (5, self.screen.get_height() - 20))
|
||||
hint_font = pygame.font.Font(None, 16)
|
||||
hint = hint_font.render(
|
||||
"SPACE: Next Turn | R: Reset | M: Mode | S: Settings | G: Graphs | ESC: Quit",
|
||||
True, (80, 85, 100)
|
||||
)
|
||||
self.screen.blit(hint, (10, self.screen_height - 22))
|
||||
|
||||
# Update display
|
||||
pygame.display.flip()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Main game loop."""
|
||||
print("Starting Village Simulation Frontend...")
|
||||
print("=" * 60)
|
||||
print(" VILLAGE SIMULATION - Economy, Religion & Diplomacy")
|
||||
print("=" * 60)
|
||||
print(f"\nScreen: {self.screen_width}x{self.screen_height} (Fullscreen)")
|
||||
print("Connecting to backend at http://localhost:8000...")
|
||||
|
||||
# Try to connect initially
|
||||
@ -292,12 +330,13 @@ class VillageSimulationApp:
|
||||
self.state = self.client.get_state()
|
||||
|
||||
print("\nControls:")
|
||||
print(" SPACE - Advance turn")
|
||||
print(" R - Reset simulation")
|
||||
print(" M - Toggle auto/manual mode")
|
||||
print(" S - Open settings")
|
||||
print(" G - Open statistics & graphs")
|
||||
print(" ESC - Close panel / Quit")
|
||||
print(" SPACE - Advance turn")
|
||||
print(" R - Reset simulation (100 agents)")
|
||||
print(" M - Toggle auto/manual mode")
|
||||
print(" S - Open settings")
|
||||
print(" G - Open statistics & graphs")
|
||||
print(" F - Toggle fullscreen")
|
||||
print(" ESC - Close panel / Quit")
|
||||
print()
|
||||
|
||||
while self.running:
|
||||
@ -311,7 +350,6 @@ class VillageSimulationApp:
|
||||
|
||||
def main():
|
||||
"""Entry point for the frontend application."""
|
||||
# Get server URL from command line if provided
|
||||
server_url = "http://localhost:8000"
|
||||
if len(sys.argv) > 1:
|
||||
server_url = sys.argv[1]
|
||||
|
||||
@ -4,6 +4,13 @@ from .map_renderer import MapRenderer
|
||||
from .agent_renderer import AgentRenderer
|
||||
from .ui_renderer import UIRenderer
|
||||
from .settings_renderer import SettingsRenderer
|
||||
from .stats_renderer import StatsRenderer
|
||||
|
||||
__all__ = ["MapRenderer", "AgentRenderer", "UIRenderer", "SettingsRenderer"]
|
||||
__all__ = [
|
||||
"MapRenderer",
|
||||
"AgentRenderer",
|
||||
"UIRenderer",
|
||||
"SettingsRenderer",
|
||||
"StatsRenderer",
|
||||
]
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""Agent renderer for the Village Simulation."""
|
||||
"""Agent renderer for the Village Simulation.
|
||||
|
||||
Optimized for 100+ agents with faction/religion color coding.
|
||||
"""
|
||||
|
||||
import math
|
||||
import pygame
|
||||
@ -9,42 +12,53 @@ if TYPE_CHECKING:
|
||||
from frontend.renderer.map_renderer import MapRenderer
|
||||
|
||||
|
||||
# Profession colors (villager is the default now)
|
||||
PROFESSION_COLORS = {
|
||||
"villager": (100, 140, 180), # Blue-gray for generic villager
|
||||
"hunter": (180, 80, 80), # Red
|
||||
"gatherer": (80, 160, 80), # Green
|
||||
"woodcutter": (139, 90, 43), # Brown
|
||||
"crafter": (160, 120, 200), # Purple
|
||||
# Faction colors - matches backend FactionType
|
||||
FACTION_COLORS = {
|
||||
"northlands": (100, 160, 220), # Ice blue
|
||||
"riverfolk": (70, 160, 180), # River teal
|
||||
"forestkin": (90, 160, 80), # Forest green
|
||||
"mountaineer": (150, 120, 90), # Mountain brown
|
||||
"plainsmen": (200, 180, 100), # Plains gold
|
||||
"neutral": (120, 120, 120), # Gray
|
||||
}
|
||||
|
||||
# Religion colors
|
||||
RELIGION_COLORS = {
|
||||
"solaris": (255, 200, 80), # Golden sun
|
||||
"aquarius": (80, 170, 240), # Ocean blue
|
||||
"terranus": (160, 120, 70), # Earth brown
|
||||
"ignis": (240, 100, 50), # Fire red
|
||||
"naturis": (100, 200, 100), # Forest green
|
||||
"atheist": (140, 140, 140), # Gray
|
||||
}
|
||||
|
||||
# Corpse color
|
||||
CORPSE_COLOR = (60, 60, 60) # Dark gray
|
||||
CORPSE_COLOR = (50, 50, 55)
|
||||
|
||||
# Status bar colors
|
||||
BAR_COLORS = {
|
||||
"energy": (255, 220, 80), # Yellow
|
||||
"hunger": (220, 140, 80), # Orange
|
||||
"thirst": (80, 160, 220), # Blue
|
||||
"heat": (220, 80, 80), # Red
|
||||
}
|
||||
|
||||
# Action icons/symbols
|
||||
# Action symbols (simplified for performance)
|
||||
ACTION_SYMBOLS = {
|
||||
"hunt": "🏹",
|
||||
"gather": "🍇",
|
||||
"chop_wood": "🪓",
|
||||
"get_water": "💧",
|
||||
"weave": "🧵",
|
||||
"build_fire": "🔥",
|
||||
"trade": "💰",
|
||||
"rest": "💤",
|
||||
"sleep": "😴",
|
||||
"consume": "🍖",
|
||||
"dead": "💀",
|
||||
"hunt": "⚔",
|
||||
"gather": "◆",
|
||||
"chop_wood": "▲",
|
||||
"get_water": "◎",
|
||||
"weave": "⊕",
|
||||
"build_fire": "◈",
|
||||
"trade": "$",
|
||||
"rest": "○",
|
||||
"sleep": "◐",
|
||||
"consume": "●",
|
||||
"drill_oil": "⛏",
|
||||
"refine": "⚙",
|
||||
"pray": "✦",
|
||||
"preach": "✧",
|
||||
"negotiate": "⚖",
|
||||
"declare_war": "⚔",
|
||||
"make_peace": "☮",
|
||||
"burn_fuel": "◈",
|
||||
"dead": "✖",
|
||||
}
|
||||
|
||||
# Fallback ASCII symbols for systems without emoji support
|
||||
# Fallback ASCII
|
||||
ACTION_LETTERS = {
|
||||
"hunt": "H",
|
||||
"gather": "G",
|
||||
@ -56,12 +70,20 @@ ACTION_LETTERS = {
|
||||
"rest": "R",
|
||||
"sleep": "Z",
|
||||
"consume": "E",
|
||||
"drill_oil": "O",
|
||||
"refine": "U",
|
||||
"pray": "P",
|
||||
"preach": "!",
|
||||
"negotiate": "N",
|
||||
"declare_war": "!",
|
||||
"make_peace": "+",
|
||||
"burn_fuel": "B",
|
||||
"dead": "X",
|
||||
}
|
||||
|
||||
|
||||
class AgentRenderer:
|
||||
"""Renders agents on the map with movement and action indicators."""
|
||||
"""Renders agents on the map with faction/religion indicators."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -72,89 +94,116 @@ class AgentRenderer:
|
||||
self.screen = screen
|
||||
self.map_renderer = map_renderer
|
||||
self.font = font
|
||||
self.small_font = pygame.font.Font(None, 16)
|
||||
self.action_font = pygame.font.Font(None, 20)
|
||||
self.small_font = pygame.font.Font(None, 14)
|
||||
self.action_font = pygame.font.Font(None, 16)
|
||||
self.tooltip_font = pygame.font.Font(None, 18)
|
||||
|
||||
# Animation state
|
||||
self.animation_tick = 0
|
||||
|
||||
# Performance: limit detail level based on agent count
|
||||
self.detail_level = 2 # 0=minimal, 1=basic, 2=full
|
||||
|
||||
def _get_faction_color(self, agent: dict) -> tuple[int, int, int]:
|
||||
"""Get agent's faction color."""
|
||||
# Faction is under diplomacy.faction (not faction.type)
|
||||
diplomacy = agent.get("diplomacy", {})
|
||||
faction = diplomacy.get("faction", "neutral")
|
||||
return FACTION_COLORS.get(faction, FACTION_COLORS["neutral"])
|
||||
|
||||
def _get_religion_color(self, agent: dict) -> tuple[int, int, int]:
|
||||
"""Get agent's religion color."""
|
||||
# Religion type is under religion.religion (not religion.type)
|
||||
religion_data = agent.get("religion", {})
|
||||
religion = religion_data.get("religion", "atheist")
|
||||
return RELIGION_COLORS.get(religion, RELIGION_COLORS["atheist"])
|
||||
|
||||
def _get_agent_color(self, agent: dict) -> tuple[int, int, int]:
|
||||
"""Get the color for an agent based on state."""
|
||||
# Corpses are dark gray
|
||||
"""Get the main color for an agent (faction-based)."""
|
||||
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
|
||||
return CORPSE_COLOR
|
||||
|
||||
profession = agent.get("profession", "villager")
|
||||
base_color = PROFESSION_COLORS.get(profession, (100, 140, 180))
|
||||
base_color = self._get_faction_color(agent)
|
||||
|
||||
if not agent.get("can_act", True):
|
||||
# Slightly dimmed for exhausted agents
|
||||
return tuple(int(c * 0.7) for c in base_color)
|
||||
# Dimmed for exhausted agents
|
||||
return tuple(int(c * 0.6) for c in base_color)
|
||||
|
||||
return base_color
|
||||
|
||||
def _draw_status_bar(
|
||||
def _draw_mini_bar(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
width: int,
|
||||
height: int,
|
||||
value: int,
|
||||
max_value: int,
|
||||
value: float,
|
||||
max_value: float,
|
||||
color: tuple[int, int, int],
|
||||
) -> None:
|
||||
"""Draw a single status bar."""
|
||||
"""Draw a tiny status bar."""
|
||||
if max_value <= 0:
|
||||
return
|
||||
|
||||
# Background
|
||||
pygame.draw.rect(self.screen, (40, 40, 40), (x, y, width, height))
|
||||
pygame.draw.rect(self.screen, (25, 25, 30), (x, y, width, height))
|
||||
|
||||
# Fill
|
||||
fill_width = int((value / max_value) * width) if max_value > 0 else 0
|
||||
fill_width = int((value / max_value) * width)
|
||||
if fill_width > 0:
|
||||
pygame.draw.rect(self.screen, color, (x, y, fill_width, height))
|
||||
|
||||
# Border
|
||||
pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1)
|
||||
# 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))
|
||||
|
||||
def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None:
|
||||
"""Draw status bars below the agent."""
|
||||
def _draw_status_bars(
|
||||
self,
|
||||
agent: dict,
|
||||
center_x: int,
|
||||
center_y: int,
|
||||
size: int
|
||||
) -> None:
|
||||
"""Draw compact status bars below the agent."""
|
||||
stats = agent.get("stats", {})
|
||||
|
||||
bar_width = size + 10
|
||||
bar_height = 3
|
||||
bar_spacing = 4
|
||||
start_y = center_y + size // 2 + 4
|
||||
bar_width = size + 6
|
||||
bar_height = 2
|
||||
bar_spacing = 3
|
||||
start_y = center_y + size // 2 + 3
|
||||
|
||||
bars = [
|
||||
("energy", stats.get("energy", 0), stats.get("max_energy", 100)),
|
||||
("hunger", stats.get("hunger", 0), stats.get("max_hunger", 100)),
|
||||
("thirst", stats.get("thirst", 0), stats.get("max_thirst", 50)),
|
||||
("heat", stats.get("heat", 0), stats.get("max_heat", 100)),
|
||||
(stats.get("energy", 0), stats.get("max_energy", 100), (220, 200, 80)),
|
||||
(stats.get("hunger", 0), stats.get("max_hunger", 100), (200, 130, 80)),
|
||||
(stats.get("thirst", 0), stats.get("max_thirst", 100), (80, 160, 200)),
|
||||
]
|
||||
|
||||
for i, (stat_name, value, max_value) in enumerate(bars):
|
||||
for i, (value, max_value, color) in enumerate(bars):
|
||||
bar_y = start_y + i * bar_spacing
|
||||
self._draw_status_bar(
|
||||
self._draw_mini_bar(
|
||||
center_x - bar_width // 2,
|
||||
bar_y,
|
||||
bar_width,
|
||||
bar_height,
|
||||
value,
|
||||
max_value,
|
||||
BAR_COLORS[stat_name],
|
||||
color,
|
||||
)
|
||||
|
||||
def _draw_action_indicator(
|
||||
def _draw_action_bubble(
|
||||
self,
|
||||
agent: dict,
|
||||
center_x: int,
|
||||
center_y: int,
|
||||
agent_size: int,
|
||||
) -> None:
|
||||
"""Draw action indicator above the agent."""
|
||||
"""Draw action indicator bubble above agent."""
|
||||
current_action = agent.get("current_action", {})
|
||||
action_type = current_action.get("action_type", "")
|
||||
is_moving = current_action.get("is_moving", False)
|
||||
message = current_action.get("message", "")
|
||||
|
||||
if not action_type:
|
||||
return
|
||||
@ -162,170 +211,191 @@ class AgentRenderer:
|
||||
# Get action symbol
|
||||
symbol = ACTION_LETTERS.get(action_type, "?")
|
||||
|
||||
# Draw action bubble above agent
|
||||
bubble_y = center_y - agent_size // 2 - 20
|
||||
# Position above agent
|
||||
bubble_y = center_y - agent_size // 2 - 12
|
||||
|
||||
# Animate if moving
|
||||
is_moving = current_action.get("is_moving", False)
|
||||
if is_moving:
|
||||
# Bouncing animation
|
||||
offset = int(3 * math.sin(self.animation_tick * 0.3))
|
||||
offset = int(2 * math.sin(self.animation_tick * 0.3))
|
||||
bubble_y += offset
|
||||
|
||||
# Draw bubble background
|
||||
bubble_width = 22
|
||||
bubble_height = 18
|
||||
# Draw small bubble
|
||||
bubble_w, bubble_h = 14, 12
|
||||
bubble_rect = pygame.Rect(
|
||||
center_x - bubble_width // 2,
|
||||
bubble_y - bubble_height // 2,
|
||||
bubble_width,
|
||||
bubble_height,
|
||||
center_x - bubble_w // 2,
|
||||
bubble_y - bubble_h // 2,
|
||||
bubble_w,
|
||||
bubble_h,
|
||||
)
|
||||
|
||||
# Color based on action success/failure
|
||||
if "Failed" in message:
|
||||
bg_color = (120, 60, 60)
|
||||
# Color based on action type
|
||||
if action_type in ["pray", "preach"]:
|
||||
bg_color = (60, 50, 80)
|
||||
border_color = (120, 100, 160)
|
||||
elif action_type in ["negotiate", "make_peace"]:
|
||||
bg_color = (50, 70, 80)
|
||||
border_color = (100, 160, 180)
|
||||
elif action_type in ["declare_war"]:
|
||||
bg_color = (80, 40, 40)
|
||||
border_color = (180, 80, 80)
|
||||
elif action_type in ["drill_oil", "refine", "burn_fuel"]:
|
||||
bg_color = (60, 55, 40)
|
||||
border_color = (140, 120, 80)
|
||||
elif is_moving:
|
||||
bg_color = (60, 80, 120)
|
||||
bg_color = (50, 60, 80)
|
||||
border_color = (100, 140, 200)
|
||||
else:
|
||||
bg_color = (50, 70, 50)
|
||||
border_color = (80, 140, 80)
|
||||
bg_color = (40, 55, 45)
|
||||
border_color = (80, 130, 90)
|
||||
|
||||
pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=4)
|
||||
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=4)
|
||||
pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=3)
|
||||
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=3)
|
||||
|
||||
# Draw action letter
|
||||
text = self.action_font.render(symbol, True, (255, 255, 255))
|
||||
# Draw symbol
|
||||
text = self.action_font.render(symbol, True, (230, 230, 230))
|
||||
text_rect = text.get_rect(center=(center_x, bubble_y))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
# Draw movement trail if moving
|
||||
if is_moving:
|
||||
target_pos = current_action.get("target_position")
|
||||
if target_pos:
|
||||
target_x, target_y = self.map_renderer.grid_to_screen(
|
||||
target_pos.get("x", 0),
|
||||
target_pos.get("y", 0),
|
||||
)
|
||||
# Draw dotted line to target
|
||||
self._draw_dotted_line(
|
||||
(center_x, center_y),
|
||||
(target_x, target_y),
|
||||
(100, 100, 100),
|
||||
4,
|
||||
)
|
||||
|
||||
def _draw_dotted_line(
|
||||
self,
|
||||
start: tuple[int, int],
|
||||
end: tuple[int, int],
|
||||
color: tuple[int, int, int],
|
||||
dot_spacing: int = 5,
|
||||
) -> None:
|
||||
"""Draw a dotted line between two points."""
|
||||
dx = end[0] - start[0]
|
||||
dy = end[1] - start[1]
|
||||
distance = max(1, int((dx ** 2 + dy ** 2) ** 0.5))
|
||||
|
||||
for i in range(0, distance, dot_spacing * 2):
|
||||
t = i / distance
|
||||
x = int(start[0] + dx * t)
|
||||
y = int(start[1] + dy * t)
|
||||
pygame.draw.circle(self.screen, color, (x, y), 1)
|
||||
|
||||
def _draw_last_action_result(
|
||||
def _draw_religion_indicator(
|
||||
self,
|
||||
agent: dict,
|
||||
center_x: int,
|
||||
center_y: int,
|
||||
agent_size: int,
|
||||
) -> None:
|
||||
"""Draw the last action result as floating text."""
|
||||
result = agent.get("last_action_result", "")
|
||||
if not result:
|
||||
return
|
||||
"""Draw a small religion indicator (faith glow)."""
|
||||
faith = agent.get("faith", 50)
|
||||
religion_color = self._get_religion_color(agent)
|
||||
|
||||
# Truncate long messages
|
||||
if len(result) > 25:
|
||||
result = result[:22] + "..."
|
||||
# Only show for agents with significant faith
|
||||
if faith > 70:
|
||||
# Divine glow effect
|
||||
glow_alpha = int((faith / 100) * 60)
|
||||
glow_surface = pygame.Surface(
|
||||
(agent_size * 2, agent_size * 2),
|
||||
pygame.SRCALPHA
|
||||
)
|
||||
pygame.draw.circle(
|
||||
glow_surface,
|
||||
(*religion_color, glow_alpha),
|
||||
(agent_size, agent_size),
|
||||
agent_size,
|
||||
)
|
||||
self.screen.blit(
|
||||
glow_surface,
|
||||
(center_x - agent_size, center_y - agent_size),
|
||||
)
|
||||
|
||||
# Draw text below status bars
|
||||
text_y = center_y + agent_size // 2 + 22
|
||||
# Small religion dot indicator
|
||||
dot_x = center_x + agent_size // 2 - 2
|
||||
dot_y = center_y - agent_size // 2 + 2
|
||||
pygame.draw.circle(self.screen, religion_color, (dot_x, dot_y), 3)
|
||||
pygame.draw.circle(self.screen, (30, 30, 35), (dot_x, dot_y), 3, 1)
|
||||
|
||||
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))
|
||||
|
||||
# Background for readability
|
||||
bg_rect = text_rect.inflate(4, 2)
|
||||
pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect)
|
||||
|
||||
self.screen.blit(text, text_rect)
|
||||
if at_war:
|
||||
# Red war indicator
|
||||
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.15)
|
||||
war_color = (int(200 * pulse), int(50 * pulse), int(50 * pulse))
|
||||
pygame.draw.circle(
|
||||
self.screen, war_color,
|
||||
(center_x - 6, center_y - 6),
|
||||
3,
|
||||
)
|
||||
|
||||
def draw(self, state: "SimulationState") -> None:
|
||||
"""Draw all agents (including corpses for one turn)."""
|
||||
"""Draw all agents (optimized for many agents)."""
|
||||
self.animation_tick += 1
|
||||
|
||||
cell_w, cell_h = self.map_renderer.get_cell_size()
|
||||
agent_size = min(cell_w, cell_h) - 8
|
||||
agent_size = max(10, min(agent_size, 30)) # Clamp size
|
||||
agent_size = min(cell_w, cell_h) - 6
|
||||
agent_size = max(8, min(agent_size, 24))
|
||||
|
||||
# Adjust detail level based on agent count
|
||||
living_count = len(state.get_living_agents())
|
||||
if living_count > 150:
|
||||
self.detail_level = 0
|
||||
elif living_count > 80:
|
||||
self.detail_level = 1
|
||||
else:
|
||||
self.detail_level = 2
|
||||
|
||||
# Separate corpses and living agents
|
||||
corpses = []
|
||||
living = []
|
||||
|
||||
for agent in state.agents:
|
||||
is_corpse = agent.get("is_corpse", False)
|
||||
is_alive = agent.get("is_alive", True)
|
||||
|
||||
# Get screen position from agent's current position
|
||||
if agent.get("is_corpse", False):
|
||||
corpses.append(agent)
|
||||
elif agent.get("is_alive", True):
|
||||
living.append(agent)
|
||||
|
||||
# Draw corpses first (behind living agents)
|
||||
for agent in corpses:
|
||||
pos = agent.get("position", {"x": 0, "y": 0})
|
||||
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
||||
self._draw_corpse(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
# Draw living agents
|
||||
for agent in living:
|
||||
pos = agent.get("position", {"x": 0, "y": 0})
|
||||
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
||||
|
||||
if is_corpse:
|
||||
# Draw corpse with death indicator
|
||||
self._draw_corpse(agent, screen_x, screen_y, agent_size)
|
||||
continue
|
||||
# Religion glow (full detail only)
|
||||
if self.detail_level >= 2:
|
||||
self._draw_religion_indicator(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
if not is_alive:
|
||||
continue
|
||||
# Action bubble (basic+ detail)
|
||||
if self.detail_level >= 1:
|
||||
self._draw_action_bubble(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
# Draw movement trail/line to target first (behind agent)
|
||||
self._draw_action_indicator(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
# Draw agent circle
|
||||
# Main agent circle with faction color
|
||||
color = self._get_agent_color(agent)
|
||||
pygame.draw.circle(self.screen, color, (screen_x, screen_y), agent_size // 2)
|
||||
pygame.draw.circle(
|
||||
self.screen, color,
|
||||
(screen_x, screen_y),
|
||||
agent_size // 2,
|
||||
)
|
||||
|
||||
# Draw border - animated if moving
|
||||
# Border - based on state
|
||||
current_action = agent.get("current_action", {})
|
||||
is_moving = current_action.get("is_moving", False)
|
||||
|
||||
if is_moving:
|
||||
# Pulsing border when moving
|
||||
pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2))
|
||||
pulse = int(180 + 75 * math.sin(self.animation_tick * 0.2))
|
||||
border_color = (pulse, pulse, 255)
|
||||
elif agent.get("can_act"):
|
||||
border_color = (255, 255, 255)
|
||||
border_color = (200, 200, 210)
|
||||
else:
|
||||
border_color = (100, 100, 100)
|
||||
border_color = (80, 80, 85)
|
||||
|
||||
pygame.draw.circle(self.screen, border_color, (screen_x, screen_y), agent_size // 2, 2)
|
||||
pygame.draw.circle(
|
||||
self.screen, border_color,
|
||||
(screen_x, screen_y),
|
||||
agent_size // 2,
|
||||
1,
|
||||
)
|
||||
|
||||
# Draw money indicator (small coin icon)
|
||||
# Money indicator
|
||||
money = agent.get("money", 0)
|
||||
if money > 0:
|
||||
coin_x = screen_x + agent_size // 2 - 4
|
||||
coin_y = screen_y - agent_size // 2 - 4
|
||||
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 4)
|
||||
pygame.draw.circle(self.screen, (200, 160, 0), (coin_x, coin_y), 4, 1)
|
||||
if money > 50:
|
||||
coin_x = screen_x + agent_size // 2 - 2
|
||||
coin_y = screen_y - agent_size // 2 - 2
|
||||
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 3)
|
||||
|
||||
# Draw "V" for villager
|
||||
text = self.small_font.render("V", True, (255, 255, 255))
|
||||
text_rect = text.get_rect(center=(screen_x, screen_y))
|
||||
self.screen.blit(text, text_rect)
|
||||
# War indicator
|
||||
if self.detail_level >= 1:
|
||||
self._draw_war_indicator(agent, screen_x, screen_y)
|
||||
|
||||
# Draw status bars
|
||||
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
# Draw last action result
|
||||
self._draw_last_action_result(agent, screen_x, screen_y, agent_size)
|
||||
# Status bars (basic+ detail)
|
||||
if self.detail_level >= 1:
|
||||
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
def _draw_corpse(
|
||||
self,
|
||||
@ -334,97 +404,117 @@ class AgentRenderer:
|
||||
center_y: int,
|
||||
agent_size: int,
|
||||
) -> None:
|
||||
"""Draw a corpse with death reason displayed."""
|
||||
# Draw corpse circle (dark gray)
|
||||
pygame.draw.circle(self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 2)
|
||||
"""Draw a corpse marker."""
|
||||
# Simple X marker
|
||||
pygame.draw.circle(
|
||||
self.screen, CORPSE_COLOR,
|
||||
(center_x, center_y),
|
||||
agent_size // 3,
|
||||
)
|
||||
pygame.draw.circle(
|
||||
self.screen, (100, 50, 50),
|
||||
(center_x, center_y),
|
||||
agent_size // 3,
|
||||
1,
|
||||
)
|
||||
|
||||
# Draw red X border
|
||||
pygame.draw.circle(self.screen, (150, 50, 50), (center_x, center_y), agent_size // 2, 2)
|
||||
|
||||
# Draw skull symbol
|
||||
text = self.action_font.render("X", True, (180, 80, 80))
|
||||
text_rect = text.get_rect(center=(center_x, center_y))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
# Draw death reason above corpse
|
||||
death_reason = agent.get("death_reason", "unknown")
|
||||
name = agent.get("name", "Unknown")
|
||||
|
||||
# Death indicator bubble
|
||||
bubble_y = center_y - agent_size // 2 - 20
|
||||
bubble_text = f"💀 {death_reason}"
|
||||
|
||||
text = self.small_font.render(bubble_text, True, (255, 100, 100))
|
||||
text_rect = text.get_rect(center=(center_x, bubble_y))
|
||||
|
||||
# Background for readability
|
||||
bg_rect = text_rect.inflate(8, 4)
|
||||
pygame.draw.rect(self.screen, (40, 20, 20), bg_rect, border_radius=3)
|
||||
pygame.draw.rect(self.screen, (120, 50, 50), bg_rect, 1, border_radius=3)
|
||||
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
# Draw name below
|
||||
name_y = center_y + agent_size // 2 + 8
|
||||
name_text = self.small_font.render(name, True, (150, 150, 150))
|
||||
name_rect = name_text.get_rect(center=(center_x, name_y))
|
||||
self.screen.blit(name_text, name_rect)
|
||||
# X symbol
|
||||
half = agent_size // 4
|
||||
pygame.draw.line(
|
||||
self.screen, (120, 60, 60),
|
||||
(center_x - half, center_y - half),
|
||||
(center_x + half, center_y + half),
|
||||
1,
|
||||
)
|
||||
pygame.draw.line(
|
||||
self.screen, (120, 60, 60),
|
||||
(center_x + half, center_y - half),
|
||||
(center_x - half, center_y + half),
|
||||
1,
|
||||
)
|
||||
|
||||
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
|
||||
"""Draw a tooltip for an agent when hovered."""
|
||||
# Build tooltip text
|
||||
lines = [
|
||||
agent.get("name", "Unknown"),
|
||||
f"Profession: {agent.get('profession', '?').capitalize()}",
|
||||
f"Money: {agent.get('money', 0)} coins",
|
||||
"",
|
||||
]
|
||||
"""Draw a detailed tooltip for hovered agent."""
|
||||
lines = []
|
||||
|
||||
# Name and faction
|
||||
name = agent.get("name", "Unknown")
|
||||
diplomacy = agent.get("diplomacy", {})
|
||||
faction = diplomacy.get("faction", "neutral").title()
|
||||
lines.append(f"{name}")
|
||||
lines.append(f"Faction: {faction}")
|
||||
|
||||
# Religion and faith
|
||||
religion_data = agent.get("religion", {})
|
||||
religion = religion_data.get("religion", "atheist").title()
|
||||
faith = religion_data.get("faith", 50)
|
||||
lines.append(f"Religion: {religion} ({faith}% faith)")
|
||||
|
||||
# Money
|
||||
money = agent.get("money", 0)
|
||||
lines.append(f"Money: {money} coins")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Stats
|
||||
stats = agent.get("stats", {})
|
||||
lines.append(f"Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}")
|
||||
lines.append(f"Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}")
|
||||
lines.append(f"Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 100)}")
|
||||
lines.append(f"Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}")
|
||||
|
||||
# Current action
|
||||
current_action = agent.get("current_action", {})
|
||||
action_type = current_action.get("action_type", "")
|
||||
if action_type:
|
||||
action_msg = current_action.get("message", action_type)
|
||||
lines.append(f"Action: {action_msg[:40]}")
|
||||
if current_action.get("is_moving"):
|
||||
lines.append(" (moving to location)")
|
||||
lines.append("")
|
||||
lines.append(f"Action: {action_type.replace('_', ' ').title()}")
|
||||
if current_action.get("is_moving"):
|
||||
lines.append(" (moving)")
|
||||
|
||||
lines.append("Stats:")
|
||||
stats = agent.get("stats", {})
|
||||
lines.append(f" Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}")
|
||||
lines.append(f" Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}")
|
||||
lines.append(f" Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 50)}")
|
||||
lines.append(f" Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}")
|
||||
|
||||
# Inventory summary
|
||||
inventory = agent.get("inventory", [])
|
||||
if inventory:
|
||||
lines.append("")
|
||||
lines.append("Inventory:")
|
||||
for item in inventory[:5]:
|
||||
lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}")
|
||||
for item in inventory[:4]:
|
||||
item_type = item.get("type", "?")
|
||||
qty = item.get("quantity", 0)
|
||||
lines.append(f" {item_type}: {qty}")
|
||||
if len(inventory) > 4:
|
||||
lines.append(f" ...+{len(inventory) - 4} more")
|
||||
|
||||
# Last action result
|
||||
last_result = agent.get("last_action_result", "")
|
||||
if last_result:
|
||||
lines.append("")
|
||||
lines.append(f"Last: {last_result[:35]}")
|
||||
|
||||
# Calculate tooltip size
|
||||
# Calculate size
|
||||
line_height = 16
|
||||
max_width = max(self.small_font.size(line)[0] for line in lines) + 20
|
||||
height = len(lines) * line_height + 10
|
||||
max_width = max(self.tooltip_font.size(line)[0] for line in lines if line) + 24
|
||||
height = len(lines) * line_height + 16
|
||||
|
||||
# Position tooltip near mouse but not off screen
|
||||
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 5)
|
||||
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 5)
|
||||
# Position
|
||||
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 10)
|
||||
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 10)
|
||||
|
||||
# Draw background
|
||||
# Background with faction color accent
|
||||
tooltip_rect = pygame.Rect(x, y, max_width, height)
|
||||
pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect)
|
||||
pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1)
|
||||
pygame.draw.rect(self.screen, (25, 28, 35), tooltip_rect, border_radius=6)
|
||||
|
||||
# Faction color accent bar
|
||||
faction_color = self._get_faction_color(agent)
|
||||
pygame.draw.rect(
|
||||
self.screen, faction_color,
|
||||
(x, y, 4, height),
|
||||
border_top_left_radius=6,
|
||||
border_bottom_left_radius=6,
|
||||
)
|
||||
|
||||
pygame.draw.rect(
|
||||
self.screen, (60, 70, 85),
|
||||
tooltip_rect, 1, border_radius=6,
|
||||
)
|
||||
|
||||
# Draw text
|
||||
for i, line in enumerate(lines):
|
||||
text = self.small_font.render(line, True, (220, 220, 220))
|
||||
self.screen.blit(text, (x + 10, y + 5 + i * line_height))
|
||||
if not line:
|
||||
continue
|
||||
color = (220, 220, 225) if i == 0 else (170, 175, 185)
|
||||
text = self.tooltip_font.render(line, True, color)
|
||||
self.screen.blit(text, (x + 12, y + 8 + i * line_height))
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
"""Map renderer for the Village Simulation."""
|
||||
"""Map renderer for the Village Simulation.
|
||||
|
||||
Beautiful dark theme with oil fields, temples, and terrain features.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import pygame
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@ -7,29 +12,58 @@ if TYPE_CHECKING:
|
||||
from frontend.client import SimulationState
|
||||
|
||||
|
||||
# Color palette
|
||||
# Color palette - Cyberpunk dark theme
|
||||
class Colors:
|
||||
# Background colors
|
||||
DAY_BG = (180, 200, 160) # Soft green for day
|
||||
NIGHT_BG = (40, 45, 60) # Dark blue for night
|
||||
GRID_LINE = (120, 140, 110) # Subtle grid lines
|
||||
GRID_LINE_NIGHT = (60, 65, 80)
|
||||
DAY_BG = (28, 35, 42)
|
||||
NIGHT_BG = (12, 14, 20)
|
||||
|
||||
# Terrain features (for visual variety)
|
||||
GRASS_LIGHT = (160, 190, 140)
|
||||
GRASS_DARK = (140, 170, 120)
|
||||
WATER_SPOT = (100, 140, 180)
|
||||
# Terrain
|
||||
GRASS_LIGHT = (32, 45, 38)
|
||||
GRASS_DARK = (26, 38, 32)
|
||||
GRASS_ACCENT = (38, 52, 44)
|
||||
WATER_SPOT = (25, 45, 65)
|
||||
WATER_DEEP = (18, 35, 55)
|
||||
|
||||
# Grid
|
||||
GRID_LINE = (45, 55, 60)
|
||||
GRID_LINE_NIGHT = (25, 30, 38)
|
||||
|
||||
# Special locations
|
||||
OIL_FIELD = (35, 35, 35)
|
||||
OIL_GLOW = (80, 70, 45)
|
||||
TEMPLE_GLOW = (100, 80, 140)
|
||||
|
||||
# Religion colors
|
||||
RELIGIONS = {
|
||||
"solaris": (255, 180, 50), # Golden sun
|
||||
"aquarius": (50, 150, 220), # Ocean blue
|
||||
"terranus": (140, 100, 60), # Earth brown
|
||||
"ignis": (220, 80, 40), # Fire red
|
||||
"naturis": (80, 180, 80), # Forest green
|
||||
"atheist": (100, 100, 100), # Gray
|
||||
}
|
||||
|
||||
# Faction colors
|
||||
FACTIONS = {
|
||||
"northlands": (100, 150, 200), # Ice blue
|
||||
"riverfolk": (60, 140, 170), # River teal
|
||||
"forestkin": (80, 140, 70), # Forest green
|
||||
"mountaineer": (130, 110, 90), # Mountain brown
|
||||
"plainsmen": (180, 160, 100), # Plains gold
|
||||
"neutral": (100, 100, 100), # Gray
|
||||
}
|
||||
|
||||
|
||||
class MapRenderer:
|
||||
"""Renders the map/terrain background."""
|
||||
"""Renders the map/terrain background with special locations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
screen: pygame.Surface,
|
||||
map_rect: pygame.Rect,
|
||||
world_width: int = 20,
|
||||
world_height: int = 20,
|
||||
world_width: int = 30,
|
||||
world_height: int = 30,
|
||||
):
|
||||
self.screen = screen
|
||||
self.map_rect = map_rect
|
||||
@ -38,24 +72,40 @@ class MapRenderer:
|
||||
self._cell_width = map_rect.width / world_width
|
||||
self._cell_height = map_rect.height / world_height
|
||||
|
||||
# Pre-generate some terrain variation
|
||||
# Animation state
|
||||
self.animation_tick = 0
|
||||
|
||||
# Pre-generate terrain
|
||||
self._terrain_cache = self._generate_terrain()
|
||||
|
||||
# Surface cache for static elements
|
||||
self._terrain_surface: pygame.Surface | None = None
|
||||
self._cached_dimensions = (world_width, world_height, map_rect.width, map_rect.height)
|
||||
|
||||
def _generate_terrain(self) -> list[list[int]]:
|
||||
"""Generate simple terrain variation (0 = light, 1 = dark, 2 = water)."""
|
||||
import random
|
||||
"""Generate terrain variation using noise-like pattern."""
|
||||
random.seed(42) # Consistent terrain
|
||||
terrain = []
|
||||
|
||||
for y in range(self.world_height):
|
||||
row = []
|
||||
for x in range(self.world_width):
|
||||
# Simple pattern: mostly grass with occasional water spots
|
||||
if random.random() < 0.05:
|
||||
row.append(2) # Water spot
|
||||
elif (x + y) % 3 == 0:
|
||||
# Create organic-looking patterns
|
||||
noise = (
|
||||
math.sin(x * 0.3) * math.cos(y * 0.3) +
|
||||
math.sin(x * 0.7 + y * 0.5) * 0.5
|
||||
)
|
||||
|
||||
if noise > 0.8:
|
||||
row.append(2) # Water
|
||||
elif noise > 0.3:
|
||||
row.append(1) # Dark grass
|
||||
elif noise < -0.5:
|
||||
row.append(3) # Accent grass
|
||||
else:
|
||||
row.append(0) # Light grass
|
||||
terrain.append(row)
|
||||
|
||||
return terrain
|
||||
|
||||
def update_dimensions(self, world_width: int, world_height: int) -> None:
|
||||
@ -66,6 +116,7 @@ class MapRenderer:
|
||||
self._cell_width = self.map_rect.width / world_width
|
||||
self._cell_height = self.map_rect.height / world_height
|
||||
self._terrain_cache = self._generate_terrain()
|
||||
self._terrain_surface = None # Invalidate cache
|
||||
|
||||
def grid_to_screen(self, grid_x: int, grid_y: int) -> tuple[int, int]:
|
||||
"""Convert grid coordinates to screen coordinates (center of cell)."""
|
||||
@ -77,70 +128,212 @@ class MapRenderer:
|
||||
"""Get the size of a single cell."""
|
||||
return int(self._cell_width), int(self._cell_height)
|
||||
|
||||
def draw(self, state: "SimulationState") -> None:
|
||||
"""Draw the map background."""
|
||||
is_night = state.time_of_day == "night"
|
||||
def _render_terrain_surface(self, is_night: bool) -> pygame.Surface:
|
||||
"""Render terrain to a cached surface."""
|
||||
surface = pygame.Surface((self.map_rect.width, self.map_rect.height))
|
||||
|
||||
# Fill background
|
||||
bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG
|
||||
pygame.draw.rect(self.screen, bg_color, self.map_rect)
|
||||
surface.fill(bg_color)
|
||||
|
||||
# Draw terrain cells
|
||||
for y in range(self.world_height):
|
||||
for x in range(self.world_width):
|
||||
cell_rect = pygame.Rect(
|
||||
self.map_rect.left + x * self._cell_width,
|
||||
self.map_rect.top + y * self._cell_height,
|
||||
self._cell_width + 1, # +1 to avoid gaps
|
||||
x * self._cell_width,
|
||||
y * self._cell_height,
|
||||
self._cell_width + 1,
|
||||
self._cell_height + 1,
|
||||
)
|
||||
|
||||
terrain_type = self._terrain_cache[y][x]
|
||||
|
||||
if is_night:
|
||||
# Darker colors at night
|
||||
if terrain_type == 2:
|
||||
color = (60, 80, 110)
|
||||
color = (15, 25, 40)
|
||||
elif terrain_type == 1:
|
||||
color = (35, 40, 55)
|
||||
color = (18, 25, 22)
|
||||
elif terrain_type == 3:
|
||||
color = (22, 30, 26)
|
||||
else:
|
||||
color = (45, 50, 65)
|
||||
color = (20, 28, 24)
|
||||
else:
|
||||
if terrain_type == 2:
|
||||
color = Colors.WATER_SPOT
|
||||
elif terrain_type == 1:
|
||||
color = Colors.GRASS_DARK
|
||||
elif terrain_type == 3:
|
||||
color = Colors.GRASS_ACCENT
|
||||
else:
|
||||
color = Colors.GRASS_LIGHT
|
||||
|
||||
pygame.draw.rect(self.screen, color, cell_rect)
|
||||
pygame.draw.rect(surface, color, cell_rect)
|
||||
|
||||
# Draw grid lines
|
||||
# Draw subtle grid
|
||||
grid_color = Colors.GRID_LINE_NIGHT if is_night else Colors.GRID_LINE
|
||||
|
||||
# Vertical lines
|
||||
for x in range(self.world_width + 1):
|
||||
start_x = self.map_rect.left + x * self._cell_width
|
||||
start_x = x * self._cell_width
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
surface,
|
||||
grid_color,
|
||||
(start_x, self.map_rect.top),
|
||||
(start_x, self.map_rect.bottom),
|
||||
(start_x, 0),
|
||||
(start_x, self.map_rect.height),
|
||||
1,
|
||||
)
|
||||
|
||||
# Horizontal lines
|
||||
for y in range(self.world_height + 1):
|
||||
start_y = self.map_rect.top + y * self._cell_height
|
||||
start_y = y * self._cell_height
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
surface,
|
||||
grid_color,
|
||||
(self.map_rect.left, start_y),
|
||||
(self.map_rect.right, start_y),
|
||||
(0, start_y),
|
||||
(self.map_rect.width, start_y),
|
||||
1,
|
||||
)
|
||||
|
||||
# Draw border
|
||||
border_color = (80, 90, 70) if not is_night else (80, 85, 100)
|
||||
return surface
|
||||
|
||||
def _draw_oil_field(self, oil_field: dict, is_night: bool) -> None:
|
||||
"""Draw an oil field with pulsing glow effect."""
|
||||
pos = oil_field.get("position", {"x": 0, "y": 0})
|
||||
screen_x, screen_y = self.grid_to_screen(pos["x"], pos["y"])
|
||||
|
||||
cell_w, cell_h = self.get_cell_size()
|
||||
radius = min(cell_w, cell_h) // 2 - 2
|
||||
|
||||
# Pulsing glow
|
||||
pulse = 0.7 + 0.3 * math.sin(self.animation_tick * 0.05)
|
||||
glow_color = tuple(int(c * pulse) for c in Colors.OIL_GLOW)
|
||||
|
||||
# Outer glow
|
||||
for i in range(3, 0, -1):
|
||||
alpha = int(30 * pulse / i)
|
||||
glow_surface = pygame.Surface((radius * 4, radius * 4), pygame.SRCALPHA)
|
||||
pygame.draw.circle(
|
||||
glow_surface,
|
||||
(*glow_color, alpha),
|
||||
(radius * 2, radius * 2),
|
||||
radius + i * 3,
|
||||
)
|
||||
self.screen.blit(
|
||||
glow_surface,
|
||||
(screen_x - radius * 2, screen_y - radius * 2),
|
||||
)
|
||||
|
||||
# Oil derrick shape
|
||||
pygame.draw.circle(self.screen, Colors.OIL_FIELD, (screen_x, screen_y), radius)
|
||||
pygame.draw.circle(self.screen, glow_color, (screen_x, screen_y), radius, 2)
|
||||
|
||||
# Derrick icon (triangle)
|
||||
points = [
|
||||
(screen_x, screen_y - radius + 2),
|
||||
(screen_x - radius // 2, screen_y + radius // 2),
|
||||
(screen_x + radius // 2, screen_y + radius // 2),
|
||||
]
|
||||
pygame.draw.polygon(self.screen, glow_color, points)
|
||||
pygame.draw.polygon(self.screen, (40, 40, 40), points, 1)
|
||||
|
||||
# Oil remaining indicator
|
||||
oil_remaining = oil_field.get("oil_remaining", 1000)
|
||||
if oil_remaining < 500:
|
||||
# Low oil warning
|
||||
warning_pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||
warning_color = (int(200 * warning_pulse), int(60 * warning_pulse), 0)
|
||||
pygame.draw.circle(
|
||||
self.screen, warning_color,
|
||||
(screen_x + radius, screen_y - radius),
|
||||
4,
|
||||
)
|
||||
|
||||
def _draw_temple(self, temple: dict, is_night: bool) -> None:
|
||||
"""Draw a temple with religion-colored glow."""
|
||||
pos = temple.get("position", {"x": 0, "y": 0})
|
||||
religion_type = temple.get("religion_type", "atheist")
|
||||
screen_x, screen_y = self.grid_to_screen(pos["x"], pos["y"])
|
||||
|
||||
cell_w, cell_h = self.get_cell_size()
|
||||
radius = min(cell_w, cell_h) // 2 - 2
|
||||
|
||||
# Get religion color
|
||||
religion_color = Colors.RELIGIONS.get(religion_type, Colors.RELIGIONS["atheist"])
|
||||
|
||||
# Pulsing glow
|
||||
pulse = 0.6 + 0.4 * math.sin(self.animation_tick * 0.03 + hash(religion_type) % 10)
|
||||
glow_color = tuple(int(c * pulse) for c in religion_color)
|
||||
|
||||
# Outer divine glow
|
||||
for i in range(4, 0, -1):
|
||||
alpha = int(40 * pulse / i)
|
||||
glow_surface = pygame.Surface((radius * 5, radius * 5), pygame.SRCALPHA)
|
||||
pygame.draw.circle(
|
||||
glow_surface,
|
||||
(*glow_color, alpha),
|
||||
(radius * 2.5, radius * 2.5),
|
||||
int(radius + i * 4),
|
||||
)
|
||||
self.screen.blit(
|
||||
glow_surface,
|
||||
(screen_x - radius * 2.5, screen_y - radius * 2.5),
|
||||
)
|
||||
|
||||
# Temple base
|
||||
pygame.draw.circle(self.screen, (40, 35, 50), (screen_x, screen_y), radius)
|
||||
pygame.draw.circle(self.screen, glow_color, (screen_x, screen_y), radius, 2)
|
||||
|
||||
# Temple icon (cross/star pattern)
|
||||
half = radius // 2
|
||||
pygame.draw.line(self.screen, glow_color,
|
||||
(screen_x, screen_y - half),
|
||||
(screen_x, screen_y + half), 2)
|
||||
pygame.draw.line(self.screen, glow_color,
|
||||
(screen_x - half, screen_y),
|
||||
(screen_x + half, screen_y), 2)
|
||||
|
||||
# Religion initial
|
||||
font = pygame.font.Font(None, max(10, radius))
|
||||
initial = religion_type[0].upper() if religion_type else "?"
|
||||
text = font.render(initial, True, (255, 255, 255))
|
||||
text_rect = text.get_rect(center=(screen_x, screen_y))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
def draw(self, state: "SimulationState") -> None:
|
||||
"""Draw the map background with all features."""
|
||||
self.animation_tick += 1
|
||||
|
||||
is_night = state.time_of_day == "night"
|
||||
|
||||
# Draw terrain (cached for performance)
|
||||
current_dims = (self.world_width, self.world_height,
|
||||
self.map_rect.width, self.map_rect.height)
|
||||
|
||||
if self._terrain_surface is None or self._cached_dimensions != current_dims:
|
||||
self._terrain_surface = self._render_terrain_surface(is_night)
|
||||
self._cached_dimensions = current_dims
|
||||
|
||||
self.screen.blit(self._terrain_surface, self.map_rect.topleft)
|
||||
|
||||
# Draw oil fields
|
||||
for oil_field in state.oil_fields:
|
||||
self._draw_oil_field(oil_field, is_night)
|
||||
|
||||
# Draw temples
|
||||
for temple in state.temples:
|
||||
self._draw_temple(temple, is_night)
|
||||
|
||||
# Draw border with glow effect
|
||||
border_color = (50, 55, 70) if not is_night else (35, 40, 55)
|
||||
pygame.draw.rect(self.screen, border_color, self.map_rect, 2)
|
||||
|
||||
|
||||
# Corner accents
|
||||
corner_size = 15
|
||||
accent_color = (80, 100, 130) if not is_night else (60, 75, 100)
|
||||
corners = [
|
||||
(self.map_rect.left, self.map_rect.top),
|
||||
(self.map_rect.right - corner_size, self.map_rect.top),
|
||||
(self.map_rect.left, self.map_rect.bottom - corner_size),
|
||||
(self.map_rect.right - corner_size, self.map_rect.bottom - corner_size),
|
||||
]
|
||||
for cx, cy in corners:
|
||||
pygame.draw.rect(self.screen, accent_color,
|
||||
(cx, cy, corner_size, corner_size), 1)
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""Settings UI renderer with sliders for the Village Simulation."""
|
||||
"""Settings UI renderer with sliders for the Village Simulation.
|
||||
|
||||
Includes settings for economy, religion, diplomacy, and oil.
|
||||
"""
|
||||
|
||||
import pygame
|
||||
from dataclasses import dataclass
|
||||
@ -7,76 +10,132 @@ from typing import Optional, Callable, Any
|
||||
|
||||
class Colors:
|
||||
"""Color palette for settings UI."""
|
||||
BG = (25, 28, 35)
|
||||
PANEL_BG = (35, 40, 50)
|
||||
PANEL_BORDER = (70, 80, 95)
|
||||
TEXT_PRIMARY = (230, 230, 235)
|
||||
TEXT_SECONDARY = (160, 165, 175)
|
||||
TEXT_HIGHLIGHT = (100, 180, 255)
|
||||
SLIDER_BG = (50, 55, 65)
|
||||
SLIDER_FILL = (80, 140, 200)
|
||||
BG = (15, 17, 23)
|
||||
PANEL_BG = (22, 26, 35)
|
||||
PANEL_HEADER = (28, 33, 45)
|
||||
PANEL_BORDER = (50, 60, 80)
|
||||
TEXT_PRIMARY = (225, 228, 235)
|
||||
TEXT_SECONDARY = (140, 150, 165)
|
||||
TEXT_HIGHLIGHT = (100, 200, 255)
|
||||
SLIDER_BG = (40, 45, 55)
|
||||
SLIDER_FILL = (70, 130, 200)
|
||||
SLIDER_HANDLE = (220, 220, 230)
|
||||
BUTTON_BG = (60, 100, 160)
|
||||
BUTTON_HOVER = (80, 120, 180)
|
||||
BUTTON_BG = (50, 90, 150)
|
||||
BUTTON_HOVER = (70, 110, 170)
|
||||
BUTTON_TEXT = (255, 255, 255)
|
||||
SUCCESS = (80, 180, 100)
|
||||
WARNING = (200, 160, 80)
|
||||
|
||||
# Section colors
|
||||
SECTION_ECONOMY = (100, 200, 255)
|
||||
SECTION_WORLD = (100, 220, 150)
|
||||
SECTION_RELIGION = (200, 150, 255)
|
||||
SECTION_DIPLOMACY = (255, 180, 100)
|
||||
SECTION_OIL = (180, 160, 100)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SliderConfig:
|
||||
"""Configuration for a slider widget."""
|
||||
name: str
|
||||
key: str # Dot-separated path like "agent_stats.max_energy"
|
||||
key: str
|
||||
min_val: float
|
||||
max_val: float
|
||||
step: float = 1.0
|
||||
is_int: bool = True
|
||||
description: str = ""
|
||||
section: str = "General"
|
||||
|
||||
|
||||
# Define all configurable parameters with sliders
|
||||
# Organized slider configs by section
|
||||
SLIDER_CONFIGS = [
|
||||
# Agent Stats Section
|
||||
SliderConfig("Max Energy", "agent_stats.max_energy", 50, 200, 10, True, "Maximum energy capacity"),
|
||||
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Maximum hunger capacity"),
|
||||
SliderConfig("Max Thirst", "agent_stats.max_thirst", 25, 100, 5, True, "Maximum thirst capacity"),
|
||||
SliderConfig("Max Heat", "agent_stats.max_heat", 50, 200, 10, True, "Maximum heat capacity"),
|
||||
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 10, 1, True, "Energy lost per turn"),
|
||||
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 10, 1, True, "Hunger lost per turn"),
|
||||
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 10, 1, True, "Thirst lost per turn"),
|
||||
SliderConfig("Heat Decay", "agent_stats.heat_decay", 1, 10, 1, True, "Heat lost per turn"),
|
||||
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.5, 0.05, False, "Threshold for survival mode"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# WORLD SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("World Width", "world.width", 15, 60, 5, True, "Grid width", "World"),
|
||||
SliderConfig("World Height", "world.height", 15, 60, 5, True, "Grid height", "World"),
|
||||
SliderConfig("Initial Agents", "world.initial_agents", 10, 200, 10, True, "Starting population", "World"),
|
||||
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day", "World"),
|
||||
SliderConfig("Starting Money", "world.starting_money", 50, 300, 25, True, "Initial coins", "World"),
|
||||
SliderConfig("Inventory Slots", "world.inventory_slots", 6, 20, 2, True, "Max items", "World"),
|
||||
|
||||
# World Section
|
||||
SliderConfig("World Width", "world.width", 10, 50, 5, True, "World grid width"),
|
||||
SliderConfig("World Height", "world.height", 10, 50, 5, True, "World grid height"),
|
||||
SliderConfig("Initial Agents", "world.initial_agents", 2, 20, 1, True, "Starting agent count"),
|
||||
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day"),
|
||||
SliderConfig("Inventory Slots", "world.inventory_slots", 5, 20, 1, True, "Agent inventory size"),
|
||||
SliderConfig("Starting Money", "world.starting_money", 50, 500, 50, True, "Initial coins per agent"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# AGENT STATS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Max Energy", "agent_stats.max_energy", 30, 150, 10, True, "Energy capacity", "Stats"),
|
||||
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Hunger capacity", "Stats"),
|
||||
SliderConfig("Max Thirst", "agent_stats.max_thirst", 50, 200, 10, True, "Thirst capacity", "Stats"),
|
||||
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 5, 1, True, "Energy lost/turn", "Stats"),
|
||||
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 8, 1, True, "Hunger lost/turn", "Stats"),
|
||||
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 8, 1, True, "Thirst lost/turn", "Stats"),
|
||||
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.4, 0.05, False, "Survival mode threshold", "Stats"),
|
||||
|
||||
# Actions Section
|
||||
SliderConfig("Hunt Energy Cost", "actions.hunt_energy", -30, -5, 5, True, "Energy spent hunting"),
|
||||
SliderConfig("Gather Energy Cost", "actions.gather_energy", -20, -1, 1, True, "Energy spent gathering"),
|
||||
SliderConfig("Hunt Success %", "actions.hunt_success", 0.3, 1.0, 0.1, False, "Hunting success chance"),
|
||||
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 100, 10, True, "Energy restored by sleep"),
|
||||
SliderConfig("Rest Restore", "actions.rest_energy", 5, 30, 5, True, "Energy restored by rest"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ACTIONS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Hunt Energy", "actions.hunt_energy", -15, -3, 1, True, "Energy cost", "Actions"),
|
||||
SliderConfig("Gather Energy", "actions.gather_energy", -8, -1, 1, True, "Energy cost", "Actions"),
|
||||
SliderConfig("Hunt Success %", "actions.hunt_success", 0.4, 1.0, 0.1, False, "Success chance", "Actions"),
|
||||
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 80, 10, True, "Energy gained", "Actions"),
|
||||
SliderConfig("Rest Restore", "actions.rest_energy", 5, 25, 5, True, "Energy gained", "Actions"),
|
||||
|
||||
# Resources Section
|
||||
SliderConfig("Meat Decay", "resources.meat_decay", 2, 20, 1, True, "Turns until meat spoils"),
|
||||
SliderConfig("Berries Decay", "resources.berries_decay", 10, 50, 5, True, "Turns until berries spoil"),
|
||||
SliderConfig("Meat Hunger +", "resources.meat_hunger", 10, 60, 5, True, "Hunger restored by meat"),
|
||||
SliderConfig("Water Thirst +", "resources.water_thirst", 20, 60, 5, True, "Thirst restored by water"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# RELIGION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Max Faith", "agent_stats.max_faith", 50, 150, 10, True, "Faith capacity", "Religion"),
|
||||
SliderConfig("Faith Decay", "agent_stats.faith_decay", 0, 5, 1, True, "Faith lost/turn", "Religion"),
|
||||
SliderConfig("Pray Faith Gain", "actions.pray_faith_gain", 10, 50, 5, True, "Faith from prayer", "Religion"),
|
||||
SliderConfig("Convert Chance", "actions.preach_convert_chance", 0.05, 0.4, 0.05, False, "Conversion rate", "Religion"),
|
||||
SliderConfig("Zealot Threshold", "religion.zealot_threshold", 0.6, 0.95, 0.05, False, "Zealot faith %", "Religion"),
|
||||
SliderConfig("Same Religion Bonus", "religion.same_religion_bonus", 0.0, 0.3, 0.05, False, "Trade bonus", "Religion"),
|
||||
|
||||
# Market Section
|
||||
SliderConfig("Discount Turns", "market.turns_before_discount", 1, 10, 1, True, "Turns before price drop"),
|
||||
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.30, 0.05, False, "Price reduction per period"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# DIPLOMACY
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Num Factions", "diplomacy.num_factions", 2, 8, 1, True, "Active factions", "Diplomacy"),
|
||||
SliderConfig("Starting Relations", "diplomacy.starting_relations", 30, 70, 5, True, "Initial relation", "Diplomacy"),
|
||||
SliderConfig("Alliance Threshold", "diplomacy.alliance_threshold", 60, 90, 5, True, "For alliance", "Diplomacy"),
|
||||
SliderConfig("War Threshold", "diplomacy.war_threshold", 10, 40, 5, True, "For war", "Diplomacy"),
|
||||
SliderConfig("Relation Decay", "diplomacy.relation_decay", 0, 5, 1, True, "Decay per turn", "Diplomacy"),
|
||||
SliderConfig("War Exhaustion", "diplomacy.war_exhaustion_rate", 1, 10, 1, True, "Exhaustion/turn", "Diplomacy"),
|
||||
SliderConfig("Peace Duration", "diplomacy.peace_treaty_duration", 10, 50, 5, True, "Treaty turns", "Diplomacy"),
|
||||
|
||||
# Simulation Section
|
||||
SliderConfig("Auto Step (s)", "auto_step_interval", 0.2, 3.0, 0.2, False, "Seconds between auto steps"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# OIL & RESOURCES
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Oil Fields", "world.oil_fields_count", 1, 10, 1, True, "Number of fields", "Oil"),
|
||||
SliderConfig("Drill Energy", "actions.drill_oil_energy", -20, -5, 1, True, "Drill cost", "Oil"),
|
||||
SliderConfig("Drill Success %", "actions.drill_oil_success", 0.3, 1.0, 0.1, False, "Success chance", "Oil"),
|
||||
SliderConfig("Oil Base Price", "economy.oil_base_price", 10, 50, 5, True, "Market price", "Oil"),
|
||||
SliderConfig("Fuel Base Price", "economy.fuel_base_price", 20, 80, 5, True, "Market price", "Oil"),
|
||||
SliderConfig("Fuel Heat", "resources.fuel_heat", 20, 60, 5, True, "Heat provided", "Oil"),
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MARKET
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Discount Turns", "market.turns_before_discount", 5, 30, 5, True, "Before price drop", "Market"),
|
||||
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.25, 0.05, False, "Per period", "Market"),
|
||||
SliderConfig("Max Markup", "economy.max_price_markup", 1.5, 4.0, 0.5, False, "Price ceiling", "Market"),
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# SIMULATION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Auto Step (s)", "auto_step_interval", 0.1, 2.0, 0.1, False, "Seconds/step", "Simulation"),
|
||||
]
|
||||
|
||||
# Section order and colors
|
||||
SECTION_ORDER = ["World", "Stats", "Actions", "Religion", "Diplomacy", "Oil", "Market", "Simulation"]
|
||||
SECTION_COLORS = {
|
||||
"World": Colors.SECTION_WORLD,
|
||||
"Stats": Colors.SECTION_ECONOMY,
|
||||
"Actions": Colors.SECTION_ECONOMY,
|
||||
"Religion": Colors.SECTION_RELIGION,
|
||||
"Diplomacy": Colors.SECTION_DIPLOMACY,
|
||||
"Oil": Colors.SECTION_OIL,
|
||||
"Market": Colors.SECTION_ECONOMY,
|
||||
"Simulation": Colors.TEXT_SECONDARY,
|
||||
}
|
||||
|
||||
|
||||
class Slider:
|
||||
"""A slider widget for adjusting numeric values."""
|
||||
@ -97,17 +156,17 @@ class Slider:
|
||||
self.hovered = False
|
||||
|
||||
def set_value(self, value: float) -> None:
|
||||
"""Set the slider value."""
|
||||
"""Set slider value."""
|
||||
self.value = max(self.config.min_val, min(self.config.max_val, value))
|
||||
if self.config.is_int:
|
||||
self.value = int(round(self.value))
|
||||
|
||||
def get_value(self) -> Any:
|
||||
"""Get the current value."""
|
||||
"""Get current value."""
|
||||
return int(self.value) if self.config.is_int else round(self.value, 2)
|
||||
|
||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||
"""Handle input events. Returns True if value changed."""
|
||||
"""Handle events."""
|
||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||
if self._slider_area().collidepoint(event.pos):
|
||||
self.dragging = True
|
||||
@ -124,27 +183,23 @@ class Slider:
|
||||
return False
|
||||
|
||||
def _slider_area(self) -> pygame.Rect:
|
||||
"""Get the actual slider track area."""
|
||||
"""Get slider track area."""
|
||||
return pygame.Rect(
|
||||
self.rect.x + 120, # Leave space for label
|
||||
self.rect.y + 15,
|
||||
self.rect.width - 180, # Leave space for value display
|
||||
20,
|
||||
self.rect.x + 130,
|
||||
self.rect.y + 12,
|
||||
self.rect.width - 200,
|
||||
16,
|
||||
)
|
||||
|
||||
def _update_from_mouse(self, mouse_x: int) -> bool:
|
||||
"""Update value based on mouse position."""
|
||||
"""Update value from mouse."""
|
||||
slider_area = self._slider_area()
|
||||
|
||||
# Calculate position as 0-1
|
||||
rel_x = mouse_x - slider_area.x
|
||||
ratio = max(0, min(1, rel_x / slider_area.width))
|
||||
|
||||
# Calculate value
|
||||
range_val = self.config.max_val - self.config.min_val
|
||||
new_value = self.config.min_val + ratio * range_val
|
||||
|
||||
# Apply step
|
||||
if self.config.step > 0:
|
||||
new_value = round(new_value / self.config.step) * self.config.step
|
||||
|
||||
@ -152,45 +207,44 @@ class Slider:
|
||||
self.set_value(new_value)
|
||||
return abs(old_value - self.value) > 0.001
|
||||
|
||||
def draw(self, screen: pygame.Surface) -> None:
|
||||
def draw(self, screen: pygame.Surface, section_color: tuple) -> None:
|
||||
"""Draw the slider."""
|
||||
# Background
|
||||
# Hover highlight
|
||||
if self.hovered:
|
||||
pygame.draw.rect(screen, (45, 50, 60), self.rect)
|
||||
pygame.draw.rect(screen, (35, 40, 50), self.rect, border_radius=4)
|
||||
|
||||
# Label
|
||||
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
|
||||
screen.blit(label, (self.rect.x + 5, self.rect.y + 5))
|
||||
screen.blit(label, (self.rect.x + 8, self.rect.y + 6))
|
||||
|
||||
# Slider track
|
||||
slider_area = self._slider_area()
|
||||
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=3)
|
||||
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=4)
|
||||
|
||||
# Slider fill
|
||||
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
|
||||
fill_width = int(ratio * slider_area.width)
|
||||
fill_rect = pygame.Rect(slider_area.x, slider_area.y, fill_width, slider_area.height)
|
||||
pygame.draw.rect(screen, Colors.SLIDER_FILL, fill_rect, border_radius=3)
|
||||
pygame.draw.rect(screen, section_color, fill_rect, border_radius=4)
|
||||
|
||||
# Handle
|
||||
handle_x = slider_area.x + fill_width
|
||||
handle_rect = pygame.Rect(handle_x - 4, slider_area.y - 2, 8, slider_area.height + 4)
|
||||
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=2)
|
||||
handle_rect = pygame.Rect(handle_x - 5, slider_area.y - 2, 10, slider_area.height + 4)
|
||||
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=3)
|
||||
|
||||
# Value display
|
||||
value_str = str(self.get_value())
|
||||
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
|
||||
value_x = self.rect.right - 50
|
||||
screen.blit(value_text, (value_x, self.rect.y + 5))
|
||||
screen.blit(value_text, (self.rect.right - 55, self.rect.y + 6))
|
||||
|
||||
# Description on hover
|
||||
if self.hovered and self.config.description:
|
||||
desc = self.small_font.render(self.config.description, True, Colors.TEXT_SECONDARY)
|
||||
screen.blit(desc, (self.rect.x + 5, self.rect.y + 25))
|
||||
screen.blit(desc, (self.rect.x + 8, self.rect.y + 24))
|
||||
|
||||
|
||||
class Button:
|
||||
"""A simple button widget."""
|
||||
"""Button widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -208,7 +262,7 @@ class Button:
|
||||
self.hovered = False
|
||||
|
||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||
"""Handle input events. Returns True if clicked."""
|
||||
"""Handle events."""
|
||||
if event.type == pygame.MOUSEMOTION:
|
||||
self.hovered = self.rect.collidepoint(event.pos)
|
||||
|
||||
@ -221,10 +275,10 @@ class Button:
|
||||
return False
|
||||
|
||||
def draw(self, screen: pygame.Surface) -> None:
|
||||
"""Draw the button."""
|
||||
"""Draw button."""
|
||||
color = Colors.BUTTON_HOVER if self.hovered else self.color
|
||||
pygame.draw.rect(screen, color, self.rect, border_radius=5)
|
||||
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=5)
|
||||
pygame.draw.rect(screen, color, self.rect, border_radius=6)
|
||||
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=6)
|
||||
|
||||
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
|
||||
text_rect = text.get_rect(center=self.rect.center)
|
||||
@ -232,21 +286,23 @@ class Button:
|
||||
|
||||
|
||||
class SettingsRenderer:
|
||||
"""Renders the settings UI panel with sliders."""
|
||||
"""Settings panel with organized sections and sliders."""
|
||||
|
||||
def __init__(self, screen: pygame.Surface):
|
||||
self.screen = screen
|
||||
self.font = pygame.font.Font(None, 24)
|
||||
self.small_font = pygame.font.Font(None, 18)
|
||||
self.title_font = pygame.font.Font(None, 32)
|
||||
self.font = pygame.font.Font(None, 22)
|
||||
self.small_font = pygame.font.Font(None, 16)
|
||||
self.title_font = pygame.font.Font(None, 28)
|
||||
self.section_font = pygame.font.Font(None, 20)
|
||||
|
||||
self.visible = False
|
||||
self.scroll_offset = 0
|
||||
self.max_scroll = 0
|
||||
self.current_section = 0
|
||||
|
||||
# Create sliders
|
||||
self.sliders: list[Slider] = []
|
||||
self.buttons: list[Button] = []
|
||||
self.section_tabs: list[pygame.Rect] = []
|
||||
self.config_data: dict = {}
|
||||
|
||||
self._create_widgets()
|
||||
@ -254,32 +310,44 @@ class SettingsRenderer:
|
||||
self.status_color = Colors.TEXT_SECONDARY
|
||||
|
||||
def _create_widgets(self) -> None:
|
||||
"""Create slider widgets."""
|
||||
panel_width = 400
|
||||
slider_height = 45
|
||||
start_y = 80
|
||||
"""Create widgets."""
|
||||
screen_w, screen_h = self.screen.get_size()
|
||||
|
||||
panel_x = (self.screen.get_width() - panel_width) // 2
|
||||
# Panel dimensions - wider for better readability
|
||||
panel_width = min(600, screen_w - 100)
|
||||
panel_height = screen_h - 80
|
||||
panel_x = (screen_w - panel_width) // 2
|
||||
panel_y = 40
|
||||
|
||||
for i, config in enumerate(SLIDER_CONFIGS):
|
||||
rect = pygame.Rect(
|
||||
panel_x + 10,
|
||||
start_y + i * slider_height,
|
||||
panel_width - 20,
|
||||
slider_height,
|
||||
)
|
||||
self.panel_rect = pygame.Rect(panel_x, panel_y, panel_width, panel_height)
|
||||
|
||||
# Tab bar for sections
|
||||
tab_height = 30
|
||||
self.tab_rect = pygame.Rect(panel_x, panel_y, panel_width, tab_height)
|
||||
|
||||
# Content area
|
||||
content_start_y = panel_y + tab_height + 10
|
||||
slider_height = 38
|
||||
|
||||
# Group sliders by section
|
||||
self.sliders_by_section: dict[str, list[Slider]] = {s: [] for s in SECTION_ORDER}
|
||||
|
||||
slider_width = panel_width - 40
|
||||
|
||||
for config in SLIDER_CONFIGS:
|
||||
rect = pygame.Rect(panel_x + 20, 0, slider_width, slider_height)
|
||||
slider = Slider(rect, config, self.font, self.small_font)
|
||||
self.sliders.append(slider)
|
||||
self.sliders_by_section[config.section].append(slider)
|
||||
|
||||
# Calculate max scroll
|
||||
total_height = len(SLIDER_CONFIGS) * slider_height + 150
|
||||
visible_height = self.screen.get_height() - 150
|
||||
self.max_scroll = max(0, total_height - visible_height)
|
||||
# Calculate positions for current section
|
||||
self._layout_current_section()
|
||||
|
||||
# Create buttons at the bottom
|
||||
button_y = self.screen.get_height() - 60
|
||||
button_width = 100
|
||||
# Buttons at bottom
|
||||
button_y = panel_y + panel_height - 50
|
||||
button_width = 120
|
||||
button_height = 35
|
||||
button_spacing = 15
|
||||
|
||||
buttons_data = [
|
||||
("Apply & Restart", self._apply_config, Colors.SUCCESS),
|
||||
@ -287,26 +355,43 @@ class SettingsRenderer:
|
||||
("Close", self.toggle, Colors.PANEL_BORDER),
|
||||
]
|
||||
|
||||
total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10
|
||||
start_x = (self.screen.get_width() - total_button_width) // 2
|
||||
total_w = len(buttons_data) * button_width + (len(buttons_data) - 1) * button_spacing
|
||||
start_x = panel_x + (panel_width - total_w) // 2
|
||||
|
||||
for i, (text, callback, color) in enumerate(buttons_data):
|
||||
rect = pygame.Rect(
|
||||
start_x + i * (button_width + 10),
|
||||
start_x + i * (button_width + button_spacing),
|
||||
button_y,
|
||||
button_width,
|
||||
button_height,
|
||||
)
|
||||
self.buttons.append(Button(rect, text, self.small_font, callback, color))
|
||||
|
||||
def _layout_current_section(self) -> None:
|
||||
"""Layout sliders for current section."""
|
||||
section = SECTION_ORDER[self.current_section]
|
||||
sliders = self.sliders_by_section[section]
|
||||
|
||||
content_y = self.panel_rect.y + 50
|
||||
slider_height = 38
|
||||
|
||||
for i, slider in enumerate(sliders):
|
||||
slider.rect.y = content_y + i * slider_height - self.scroll_offset
|
||||
|
||||
# Calculate max scroll
|
||||
total_height = len(sliders) * slider_height
|
||||
visible_height = self.panel_rect.height - 120
|
||||
self.max_scroll = max(0, total_height - visible_height)
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle settings visibility."""
|
||||
"""Toggle visibility."""
|
||||
self.visible = not self.visible
|
||||
if self.visible:
|
||||
self.scroll_offset = 0
|
||||
self._layout_current_section()
|
||||
|
||||
def set_config(self, config_data: dict) -> None:
|
||||
"""Set slider values from config data."""
|
||||
"""Set slider values from config."""
|
||||
self.config_data = config_data
|
||||
|
||||
for slider in self.sliders:
|
||||
@ -315,16 +400,14 @@ class SettingsRenderer:
|
||||
slider.set_value(value)
|
||||
|
||||
def get_config(self) -> dict:
|
||||
"""Get current config from slider values."""
|
||||
"""Get config from sliders."""
|
||||
result = {}
|
||||
|
||||
for slider in self.sliders:
|
||||
self._set_nested_value(result, slider.config.key, slider.get_value())
|
||||
|
||||
return result
|
||||
|
||||
def _get_nested_value(self, data: dict, key: str) -> Any:
|
||||
"""Get a value from nested dict using dot notation."""
|
||||
"""Get nested dict value."""
|
||||
parts = key.split(".")
|
||||
current = data
|
||||
for part in parts:
|
||||
@ -335,7 +418,7 @@ class SettingsRenderer:
|
||||
return current
|
||||
|
||||
def _set_nested_value(self, data: dict, key: str, value: Any) -> None:
|
||||
"""Set a value in nested dict using dot notation."""
|
||||
"""Set nested dict value."""
|
||||
parts = key.split(".")
|
||||
current = data
|
||||
for part in parts[:-1]:
|
||||
@ -345,104 +428,138 @@ class SettingsRenderer:
|
||||
current[parts[-1]] = value
|
||||
|
||||
def _apply_config(self) -> None:
|
||||
"""Apply configuration callback (to be set externally)."""
|
||||
"""Apply config callback."""
|
||||
self.status_message = "Config applied - restart to see changes"
|
||||
self.status_color = Colors.SUCCESS
|
||||
|
||||
def _reset_config(self) -> None:
|
||||
"""Reset configuration callback (to be set externally)."""
|
||||
"""Reset config callback."""
|
||||
self.status_message = "Config reset to defaults"
|
||||
self.status_color = Colors.WARNING
|
||||
|
||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||
"""Handle input events. Returns True if event was consumed."""
|
||||
"""Handle events."""
|
||||
if not self.visible:
|
||||
return False
|
||||
|
||||
# Handle scrolling
|
||||
# Tab clicks
|
||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||
if self.tab_rect.collidepoint(event.pos):
|
||||
tab_width = self.panel_rect.width // len(SECTION_ORDER)
|
||||
rel_x = event.pos[0] - self.tab_rect.x
|
||||
tab_idx = rel_x // tab_width
|
||||
if 0 <= tab_idx < len(SECTION_ORDER) and tab_idx != self.current_section:
|
||||
self.current_section = tab_idx
|
||||
self.scroll_offset = 0
|
||||
self._layout_current_section()
|
||||
return True
|
||||
|
||||
# Scrolling
|
||||
if event.type == pygame.MOUSEWHEEL:
|
||||
self.scroll_offset -= event.y * 30
|
||||
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
|
||||
self._layout_current_section()
|
||||
return True
|
||||
|
||||
# Handle sliders
|
||||
for slider in self.sliders:
|
||||
# Adjust slider position for scroll
|
||||
original_y = slider.rect.y
|
||||
slider.rect.y -= self.scroll_offset
|
||||
# Sliders for current section
|
||||
section = SECTION_ORDER[self.current_section]
|
||||
for slider in self.sliders_by_section[section]:
|
||||
adjusted_rect = slider.rect.copy()
|
||||
|
||||
if slider.handle_event(event):
|
||||
slider.rect.y = original_y
|
||||
return True
|
||||
|
||||
slider.rect.y = original_y
|
||||
|
||||
# Handle buttons
|
||||
# Buttons
|
||||
for button in self.buttons:
|
||||
if button.handle_event(event):
|
||||
return True
|
||||
|
||||
# Consume all clicks when settings are visible
|
||||
# Consume clicks
|
||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def draw(self) -> None:
|
||||
"""Draw the settings panel."""
|
||||
"""Draw settings panel."""
|
||||
if not self.visible:
|
||||
return
|
||||
|
||||
# Dim background
|
||||
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
||||
overlay.fill((0, 0, 0, 200))
|
||||
overlay.fill((0, 0, 0, 220))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
|
||||
# Panel background
|
||||
panel_width = 420
|
||||
panel_height = self.screen.get_height() - 40
|
||||
panel_x = (self.screen.get_width() - panel_width) // 2
|
||||
panel_rect = pygame.Rect(panel_x, 20, panel_width, panel_height)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, panel_rect, border_radius=10)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, panel_rect, 2, border_radius=10)
|
||||
# Panel
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, self.panel_rect, border_radius=10)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, self.panel_rect, 2, border_radius=10)
|
||||
|
||||
# Title
|
||||
title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY)
|
||||
title_rect = title.get_rect(centerx=self.screen.get_width() // 2, y=35)
|
||||
title_rect = title.get_rect(centerx=self.panel_rect.centerx, y=self.panel_rect.y + 8)
|
||||
self.screen.blit(title, title_rect)
|
||||
|
||||
# Create clipping region for scrollable area
|
||||
clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130)
|
||||
# Section tabs
|
||||
self._draw_section_tabs()
|
||||
|
||||
# Draw sliders with scroll offset
|
||||
for slider in self.sliders:
|
||||
# Adjust position for scroll
|
||||
adjusted_rect = slider.rect.copy()
|
||||
adjusted_rect.y -= self.scroll_offset
|
||||
|
||||
# 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
|
||||
# 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 scroll indicator
|
||||
# Draw sliders for current section
|
||||
section = SECTION_ORDER[self.current_section]
|
||||
section_color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
|
||||
|
||||
for slider in self.sliders_by_section[section]:
|
||||
if clip_rect.colliderect(slider.rect):
|
||||
slider.draw(self.screen, section_color)
|
||||
|
||||
# Scroll indicator
|
||||
if self.max_scroll > 0:
|
||||
scroll_ratio = self.scroll_offset / self.max_scroll
|
||||
scroll_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
|
||||
scroll_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - scroll_height))
|
||||
scroll_rect = pygame.Rect(panel_rect.right - 8, scroll_y, 4, scroll_height)
|
||||
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, scroll_rect, border_radius=2)
|
||||
bar_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
|
||||
bar_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - bar_height))
|
||||
bar_rect = pygame.Rect(self.panel_rect.right - 12, bar_y, 5, bar_height)
|
||||
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, bar_rect, border_radius=2)
|
||||
|
||||
# Draw buttons
|
||||
# Buttons
|
||||
for button in self.buttons:
|
||||
button.draw(self.screen)
|
||||
|
||||
# Status message
|
||||
if self.status_message:
|
||||
status = self.small_font.render(self.status_message, True, self.status_color)
|
||||
status_rect = status.get_rect(centerx=self.screen.get_width() // 2, y=self.screen.get_height() - 90)
|
||||
status_rect = status.get_rect(
|
||||
centerx=self.panel_rect.centerx,
|
||||
y=self.panel_rect.bottom - 80
|
||||
)
|
||||
self.screen.blit(status, status_rect)
|
||||
|
||||
|
||||
def _draw_section_tabs(self) -> None:
|
||||
"""Draw section tabs."""
|
||||
tab_width = self.panel_rect.width // len(SECTION_ORDER)
|
||||
tab_y = self.panel_rect.y + 32
|
||||
tab_height = 20
|
||||
|
||||
for i, section in enumerate(SECTION_ORDER):
|
||||
tab_x = self.panel_rect.x + i * tab_width
|
||||
tab_rect = pygame.Rect(tab_x, tab_y, tab_width, tab_height)
|
||||
|
||||
color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
|
||||
|
||||
if i == self.current_section:
|
||||
pygame.draw.rect(self.screen, color, tab_rect, border_radius=3)
|
||||
text_color = Colors.BG
|
||||
else:
|
||||
pygame.draw.rect(self.screen, (40, 45, 55), tab_rect, border_radius=3)
|
||||
text_color = color
|
||||
|
||||
# Section name (abbreviated)
|
||||
name = section[:5] if len(section) > 5 else section
|
||||
text = self.small_font.render(name, True, text_color)
|
||||
text_rect = text.get_rect(center=tab_rect.center)
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,217 +1,591 @@
|
||||
"""UI renderer for the Village Simulation."""
|
||||
"""UI renderer for the Village Simulation.
|
||||
|
||||
Beautiful dark theme with panels for statistics, factions, religion, and diplomacy.
|
||||
"""
|
||||
|
||||
import math
|
||||
import pygame
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frontend.client import SimulationState
|
||||
|
||||
|
||||
class Colors:
|
||||
# UI colors
|
||||
PANEL_BG = (35, 40, 50)
|
||||
PANEL_BORDER = (70, 80, 95)
|
||||
TEXT_PRIMARY = (230, 230, 235)
|
||||
TEXT_SECONDARY = (160, 165, 175)
|
||||
TEXT_HIGHLIGHT = (100, 180, 255)
|
||||
# Base UI colors - dark cyberpunk theme
|
||||
BG = (15, 17, 23)
|
||||
PANEL_BG = (22, 26, 35)
|
||||
PANEL_HEADER = (28, 33, 45)
|
||||
PANEL_BORDER = (45, 55, 70)
|
||||
PANEL_ACCENT = (60, 80, 110)
|
||||
|
||||
# Text
|
||||
TEXT_PRIMARY = (225, 228, 235)
|
||||
TEXT_SECONDARY = (140, 150, 165)
|
||||
TEXT_HIGHLIGHT = (100, 200, 255)
|
||||
TEXT_WARNING = (255, 180, 80)
|
||||
TEXT_DANGER = (255, 100, 100)
|
||||
TEXT_SUCCESS = (100, 220, 140)
|
||||
|
||||
# Day/Night indicator
|
||||
# Day/Night
|
||||
DAY_COLOR = (255, 220, 100)
|
||||
NIGHT_COLOR = (100, 120, 180)
|
||||
NIGHT_COLOR = (100, 140, 200)
|
||||
|
||||
# Faction colors
|
||||
FACTIONS = {
|
||||
"northlands": (100, 160, 220),
|
||||
"riverfolk": (70, 160, 180),
|
||||
"forestkin": (90, 160, 80),
|
||||
"mountaineer": (150, 120, 90),
|
||||
"plainsmen": (200, 180, 100),
|
||||
"neutral": (120, 120, 120),
|
||||
}
|
||||
|
||||
# Religion colors
|
||||
RELIGIONS = {
|
||||
"solaris": (255, 200, 80),
|
||||
"aquarius": (80, 170, 240),
|
||||
"terranus": (160, 120, 70),
|
||||
"ignis": (240, 100, 50),
|
||||
"naturis": (100, 200, 100),
|
||||
"atheist": (140, 140, 140),
|
||||
}
|
||||
|
||||
# Scrollbar
|
||||
SCROLLBAR_BG = (35, 40, 50)
|
||||
SCROLLBAR_HANDLE = (70, 90, 120)
|
||||
|
||||
|
||||
class UIRenderer:
|
||||
"""Renders UI elements (HUD, panels, text info)."""
|
||||
|
||||
def __init__(self, screen: pygame.Surface, font: pygame.font.Font):
|
||||
def __init__(
|
||||
self,
|
||||
screen: pygame.Surface,
|
||||
font: pygame.font.Font,
|
||||
top_panel_height: int = 50,
|
||||
right_panel_width: int = 280,
|
||||
bottom_panel_height: int = 60,
|
||||
):
|
||||
self.screen = screen
|
||||
self.font = font
|
||||
self.small_font = pygame.font.Font(None, 20)
|
||||
self.title_font = pygame.font.Font(None, 28)
|
||||
self.top_panel_height = top_panel_height
|
||||
self.right_panel_width = right_panel_width
|
||||
self.bottom_panel_height = bottom_panel_height
|
||||
|
||||
# Panel dimensions
|
||||
self.top_panel_height = 50
|
||||
self.right_panel_width = 200
|
||||
# Fonts
|
||||
self.small_font = pygame.font.Font(None, 16)
|
||||
self.medium_font = pygame.font.Font(None, 20)
|
||||
self.title_font = pygame.font.Font(None, 24)
|
||||
self.large_font = pygame.font.Font(None, 28)
|
||||
|
||||
# Scrolling state for right panel
|
||||
self.scroll_offset = 0
|
||||
self.max_scroll = 0
|
||||
self.scroll_dragging = False
|
||||
|
||||
# Animation
|
||||
self.animation_tick = 0
|
||||
|
||||
def _draw_panel(self, rect: pygame.Rect, title: Optional[str] = None) -> None:
|
||||
"""Draw a panel background."""
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1)
|
||||
def _draw_panel_bg(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
title: str = None,
|
||||
accent_color: tuple = None,
|
||||
) -> int:
|
||||
"""Draw a panel background. Returns Y position after header."""
|
||||
# Main background
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect, border_radius=4)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1, border_radius=4)
|
||||
|
||||
y = rect.y + 6
|
||||
|
||||
if title:
|
||||
title_text = self.small_font.render(title, True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(title_text, (rect.x + 8, rect.y + 4))
|
||||
# Header area
|
||||
header_rect = pygame.Rect(rect.x, rect.y, rect.width, 24)
|
||||
pygame.draw.rect(
|
||||
self.screen, Colors.PANEL_HEADER, header_rect,
|
||||
border_top_left_radius=4, border_top_right_radius=4,
|
||||
)
|
||||
|
||||
# Accent line
|
||||
if accent_color:
|
||||
pygame.draw.line(
|
||||
self.screen, accent_color,
|
||||
(rect.x + 2, rect.y + 24),
|
||||
(rect.x + rect.width - 2, rect.y + 24),
|
||||
2,
|
||||
)
|
||||
|
||||
# Title
|
||||
text = self.medium_font.render(title, True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(text, (rect.x + 10, rect.y + 5))
|
||||
y = rect.y + 30
|
||||
|
||||
return y
|
||||
|
||||
def _draw_progress_bar(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
width: int,
|
||||
height: int,
|
||||
value: float,
|
||||
max_value: float,
|
||||
color: tuple,
|
||||
bg_color: tuple = (35, 40, 50),
|
||||
show_label: bool = False,
|
||||
label: str = "",
|
||||
) -> None:
|
||||
"""Draw a styled progress bar."""
|
||||
# Background
|
||||
pygame.draw.rect(self.screen, bg_color, (x, y, width, height), border_radius=2)
|
||||
|
||||
# Fill
|
||||
if max_value > 0:
|
||||
ratio = min(1.0, value / max_value)
|
||||
fill_width = int(ratio * width)
|
||||
if fill_width > 0:
|
||||
pygame.draw.rect(
|
||||
self.screen, color,
|
||||
(x, y, fill_width, height),
|
||||
border_radius=2,
|
||||
)
|
||||
|
||||
# Label
|
||||
if show_label and label:
|
||||
text = self.small_font.render(label, True, Colors.TEXT_PRIMARY)
|
||||
text_rect = text.get_rect(midleft=(x + 4, y + height // 2))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
def draw_top_bar(self, state: "SimulationState") -> None:
|
||||
"""Draw the top information bar."""
|
||||
self.animation_tick += 1
|
||||
|
||||
rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
Colors.PANEL_BORDER,
|
||||
(0, self.top_panel_height),
|
||||
(self.screen.get_width(), self.top_panel_height),
|
||||
self.screen, Colors.PANEL_BORDER,
|
||||
(0, self.top_panel_height - 1),
|
||||
(self.screen.get_width(), self.top_panel_height - 1),
|
||||
)
|
||||
|
||||
# Day/Night and Turn info
|
||||
# Day/Night indicator with animated glow
|
||||
is_night = state.time_of_day == "night"
|
||||
time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR
|
||||
time_text = "NIGHT" if is_night else "DAY"
|
||||
|
||||
# Draw time indicator circle
|
||||
pygame.draw.circle(self.screen, time_color, (25, 25), 12)
|
||||
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (25, 25), 12, 1)
|
||||
# Glow effect
|
||||
glow_alpha = int(100 + 50 * math.sin(self.animation_tick * 0.05))
|
||||
glow_surface = pygame.Surface((40, 40), pygame.SRCALPHA)
|
||||
pygame.draw.circle(glow_surface, (*time_color, glow_alpha), (20, 20), 18)
|
||||
self.screen.blit(glow_surface, (10, 5))
|
||||
|
||||
# Time/day text
|
||||
# Time circle
|
||||
pygame.draw.circle(self.screen, time_color, (30, 25), 12)
|
||||
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (30, 25), 12, 1)
|
||||
|
||||
# Time/turn info
|
||||
info_text = f"{time_text} | Day {state.day}, Step {state.step_in_day} | Turn {state.turn}"
|
||||
text = self.font.render(info_text, True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(text, (50, 15))
|
||||
self.screen.blit(text, (55, 14))
|
||||
|
||||
# Agent count
|
||||
living = len(state.get_living_agents())
|
||||
total = len(state.agents)
|
||||
agent_text = f"Population: {living}/{total}"
|
||||
color = Colors.TEXT_SUCCESS if living > total * 0.5 else Colors.TEXT_WARNING
|
||||
if living < total * 0.25:
|
||||
color = Colors.TEXT_DANGER
|
||||
text = self.medium_font.render(agent_text, True, color)
|
||||
self.screen.blit(text, (300, 16))
|
||||
|
||||
# Active wars indicator
|
||||
active_wars = len(state.active_wars)
|
||||
if active_wars > 0:
|
||||
war_pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||
war_color = (int(200 * war_pulse), int(60 * war_pulse), int(60 * war_pulse))
|
||||
war_text = f"⚔ {active_wars} WAR{'S' if active_wars > 1 else ''}"
|
||||
text = self.medium_font.render(war_text, True, war_color)
|
||||
self.screen.blit(text, (450, 16))
|
||||
|
||||
# Mode and status (right side)
|
||||
right_x = self.screen.get_width() - 180
|
||||
|
||||
# Mode indicator
|
||||
mode_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY
|
||||
mode_text = f"Mode: {state.mode.upper()}"
|
||||
text = self.small_font.render(mode_text, True, mode_color)
|
||||
self.screen.blit(text, (self.screen.get_width() - 120, 8))
|
||||
text = self.medium_font.render(mode_text, True, mode_color)
|
||||
self.screen.blit(text, (right_x, 10))
|
||||
|
||||
# Running indicator
|
||||
# Running status
|
||||
if state.is_running:
|
||||
status_text = "RUNNING"
|
||||
status_color = (100, 200, 100)
|
||||
status_text = "● RUNNING"
|
||||
status_color = Colors.TEXT_SUCCESS
|
||||
else:
|
||||
status_text = "STOPPED"
|
||||
status_color = Colors.TEXT_DANGER
|
||||
status_text = "○ STOPPED"
|
||||
status_color = Colors.TEXT_SECONDARY
|
||||
|
||||
text = self.small_font.render(status_text, True, status_color)
|
||||
self.screen.blit(text, (self.screen.get_width() - 120, 28))
|
||||
text = self.medium_font.render(status_text, True, status_color)
|
||||
self.screen.blit(text, (right_x, 28))
|
||||
|
||||
def draw_right_panel(self, state: "SimulationState") -> None:
|
||||
"""Draw the right information panel."""
|
||||
"""Draw the right information panel with scrollable content."""
|
||||
panel_x = self.screen.get_width() - self.right_panel_width
|
||||
panel_height = self.screen.get_height() - self.top_panel_height - self.bottom_panel_height
|
||||
|
||||
# Main panel background
|
||||
rect = pygame.Rect(
|
||||
panel_x,
|
||||
self.top_panel_height,
|
||||
self.right_panel_width,
|
||||
self.screen.get_height() - self.top_panel_height,
|
||||
panel_x, self.top_panel_height,
|
||||
self.right_panel_width, panel_height,
|
||||
)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
Colors.PANEL_BORDER,
|
||||
self.screen, Colors.PANEL_BORDER,
|
||||
(panel_x, self.top_panel_height),
|
||||
(panel_x, self.screen.get_height()),
|
||||
(panel_x, self.screen.get_height() - self.bottom_panel_height),
|
||||
)
|
||||
|
||||
# Content area with padding
|
||||
content_x = panel_x + 12
|
||||
content_width = self.right_panel_width - 24
|
||||
y = self.top_panel_height + 10
|
||||
|
||||
# Statistics section
|
||||
y = self._draw_statistics_section(state, panel_x + 10, y)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# STATISTICS SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_stats_section(state, content_x, y, content_width)
|
||||
y += 15
|
||||
|
||||
# Market section
|
||||
y = self._draw_market_section(state, panel_x + 10, y + 20)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# FACTIONS SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_factions_section(state, content_x, y, content_width)
|
||||
y += 15
|
||||
|
||||
# Controls help section
|
||||
self._draw_controls_help(panel_x + 10, self.screen.get_height() - 100)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# RELIGION SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_religion_section(state, content_x, y, content_width)
|
||||
y += 15
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# DIPLOMACY SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_diplomacy_section(state, content_x, y, content_width)
|
||||
y += 15
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MARKET SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_market_section(state, content_x, y, content_width)
|
||||
|
||||
def _draw_statistics_section(self, state: "SimulationState", x: int, y: int) -> int:
|
||||
"""Draw the statistics section."""
|
||||
# Title
|
||||
title = self.title_font.render("Statistics", True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(title, (x, y))
|
||||
y += 30
|
||||
def _draw_stats_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw statistics section."""
|
||||
# Section header
|
||||
text = self.title_font.render("📊 Statistics", True, Colors.TEXT_HIGHLIGHT)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 24
|
||||
|
||||
stats = state.statistics
|
||||
|
||||
# Population bar
|
||||
living = len(state.get_living_agents())
|
||||
total = len(state.agents)
|
||||
pop_color = Colors.TEXT_SUCCESS if living > total * 0.5 else Colors.TEXT_WARNING
|
||||
|
||||
# Population
|
||||
pop_color = Colors.TEXT_PRIMARY if living > 2 else Colors.TEXT_DANGER
|
||||
text = self.small_font.render(f"Population: {living}", True, pop_color)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 18
|
||||
pygame.draw.rect(
|
||||
self.screen, Colors.SCROLLBAR_BG,
|
||||
(x, y, width, 14), border_radius=2,
|
||||
)
|
||||
if total > 0:
|
||||
ratio = living / total
|
||||
pygame.draw.rect(
|
||||
self.screen, pop_color,
|
||||
(x, y, int(width * ratio), 14), border_radius=2,
|
||||
)
|
||||
pop_text = self.small_font.render(f"Pop: {living}/{total}", True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(pop_text, (x + 4, y + 1))
|
||||
y += 20
|
||||
|
||||
# Deaths
|
||||
# Deaths and money
|
||||
deaths = stats.get("total_agents_died", 0)
|
||||
if deaths > 0:
|
||||
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_WARNING)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 18
|
||||
|
||||
# Total money
|
||||
total_money = stats.get("total_money_in_circulation", 0)
|
||||
text = self.small_font.render(f"Total Coins: {total_money}", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 18
|
||||
|
||||
# Professions
|
||||
professions = stats.get("professions", {})
|
||||
if professions:
|
||||
y += 5
|
||||
text = self.small_font.render("Professions:", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 16
|
||||
|
||||
for prof, count in professions.items():
|
||||
text = self.small_font.render(f" {prof}: {count}", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_DANGER)
|
||||
self.screen.blit(text, (x, y))
|
||||
|
||||
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_market_section(self, state: "SimulationState", x: int, y: int) -> int:
|
||||
"""Draw the market section."""
|
||||
# Title
|
||||
title = self.title_font.render("Market", True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(title, (x, y))
|
||||
y += 30
|
||||
def _draw_factions_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw factions section with distribution bars."""
|
||||
# Section header
|
||||
text = self.title_font.render("⚔ Factions", True, (180, 160, 120))
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 22
|
||||
|
||||
faction_stats = state.get_faction_stats()
|
||||
total = sum(faction_stats.values()) or 1
|
||||
|
||||
# Sort by count
|
||||
sorted_factions = sorted(
|
||||
faction_stats.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for faction, count in sorted_factions[:5]: # Top 5
|
||||
color = Colors.FACTIONS.get(faction, Colors.FACTIONS["neutral"])
|
||||
ratio = count / total
|
||||
|
||||
# Faction bar
|
||||
bar_width = int(width * 0.6 * ratio * (total / max(1, sorted_factions[0][1])))
|
||||
bar_width = max(4, min(bar_width, int(width * 0.6)))
|
||||
|
||||
pygame.draw.rect(
|
||||
self.screen, (*color, 180),
|
||||
(x, y, bar_width, 10), border_radius=2,
|
||||
)
|
||||
|
||||
# Faction name and count
|
||||
label = f"{faction[:8]}: {count}"
|
||||
text = self.small_font.render(label, True, color)
|
||||
self.screen.blit(text, (x + bar_width + 8, y - 1))
|
||||
y += 14
|
||||
|
||||
return y
|
||||
|
||||
def _draw_religion_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw religion section with distribution."""
|
||||
# Section header
|
||||
text = self.title_font.render("✦ Religions", True, (200, 180, 220))
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 22
|
||||
|
||||
religion_stats = state.get_religion_stats()
|
||||
total = sum(religion_stats.values()) or 1
|
||||
|
||||
# Sort by count
|
||||
sorted_religions = sorted(
|
||||
religion_stats.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for religion, count in sorted_religions[:5]: # Top 5
|
||||
color = Colors.RELIGIONS.get(religion, Colors.RELIGIONS["atheist"])
|
||||
ratio = count / total
|
||||
|
||||
# Religion color dot
|
||||
pygame.draw.circle(self.screen, color, (x + 5, y + 5), 4)
|
||||
|
||||
# Religion name, count, and percentage
|
||||
pct = ratio * 100
|
||||
label = f"{religion[:8]}: {count} ({pct:.0f}%)"
|
||||
text = self.small_font.render(label, True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x + 14, y))
|
||||
y += 14
|
||||
|
||||
return y
|
||||
|
||||
def _draw_diplomacy_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw diplomacy section with wars and treaties."""
|
||||
# Section header
|
||||
text = self.title_font.render("🏛 Diplomacy", True, (120, 180, 200))
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 22
|
||||
|
||||
# Active wars
|
||||
active_wars = state.active_wars
|
||||
if active_wars:
|
||||
text = self.small_font.render("Active Wars:", True, Colors.TEXT_DANGER)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
|
||||
for war in active_wars[:3]: # Show up to 3 wars
|
||||
f1 = war.get("faction1", "?")[:6]
|
||||
f2 = war.get("faction2", "?")[:6]
|
||||
c1 = Colors.FACTIONS.get(war.get("faction1", "neutral"), (150, 150, 150))
|
||||
c2 = Colors.FACTIONS.get(war.get("faction2", "neutral"), (150, 150, 150))
|
||||
|
||||
# War indicator
|
||||
pygame.draw.circle(self.screen, c1, (x + 5, y + 5), 4)
|
||||
text = self.small_font.render(" ⚔ ", True, (200, 80, 80))
|
||||
self.screen.blit(text, (x + 12, y - 1))
|
||||
pygame.draw.circle(self.screen, c2, (x + 35, y + 5), 4)
|
||||
|
||||
war_text = f"{f1} vs {f2}"
|
||||
text = self.small_font.render(war_text, True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x + 45, y))
|
||||
y += 14
|
||||
else:
|
||||
text = self.small_font.render("☮ No active wars", True, Colors.TEXT_SUCCESS)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
|
||||
# Peace treaties
|
||||
peace_treaties = state.peace_treaties
|
||||
if peace_treaties:
|
||||
text = self.small_font.render(
|
||||
f"Peace Treaties: {len(peace_treaties)}",
|
||||
True, Colors.TEXT_SUCCESS
|
||||
)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
|
||||
# Recent diplomatic events
|
||||
recent_events = state.diplomatic_events[:2]
|
||||
if recent_events:
|
||||
y += 4
|
||||
for event in recent_events:
|
||||
event_type = event.get("type", "unknown")
|
||||
if event_type == "war_declared":
|
||||
color = Colors.TEXT_DANGER
|
||||
icon = "⚔"
|
||||
elif event_type == "peace_made":
|
||||
color = Colors.TEXT_SUCCESS
|
||||
icon = "☮"
|
||||
else:
|
||||
color = Colors.TEXT_SECONDARY
|
||||
icon = "•"
|
||||
|
||||
desc = event.get("description", event_type)[:25]
|
||||
text = self.small_font.render(f"{icon} {desc}", True, color)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 12
|
||||
|
||||
return y
|
||||
|
||||
def _draw_market_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw market section with prices."""
|
||||
# Section header
|
||||
text = self.title_font.render("💹 Market", True, (100, 200, 150))
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 22
|
||||
|
||||
# Order count
|
||||
order_count = len(state.market_orders)
|
||||
text = self.small_font.render(f"Active Orders: {order_count}", True, Colors.TEXT_SECONDARY)
|
||||
text = self.small_font.render(
|
||||
f"Active Orders: {order_count}",
|
||||
True, Colors.TEXT_SECONDARY
|
||||
)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 20
|
||||
y += 16
|
||||
|
||||
# Price summary for each resource with available stock
|
||||
# Price summary (show resources with stock)
|
||||
prices = state.market_prices
|
||||
for resource, data in prices.items():
|
||||
if data.get("total_available", 0) > 0:
|
||||
shown = 0
|
||||
for resource, data in sorted(prices.items()):
|
||||
if shown >= 6: # Limit display
|
||||
break
|
||||
|
||||
total_available = data.get("total_available", 0)
|
||||
if total_available > 0:
|
||||
price = data.get("lowest_price", "?")
|
||||
qty = data.get("total_available", 0)
|
||||
text = self.small_font.render(
|
||||
f"{resource}: {qty}x @ {price}c",
|
||||
True,
|
||||
Colors.TEXT_SECONDARY,
|
||||
)
|
||||
|
||||
# Resource color coding
|
||||
if "oil" in resource.lower() or "fuel" in resource.lower():
|
||||
res_color = (180, 160, 100)
|
||||
elif "meat" in resource.lower():
|
||||
res_color = (200, 120, 100)
|
||||
elif "water" in resource.lower():
|
||||
res_color = (100, 160, 200)
|
||||
else:
|
||||
res_color = Colors.TEXT_SECONDARY
|
||||
|
||||
res_text = f"{resource[:6]}: {total_available}x @ {price}c"
|
||||
text = self.small_font.render(res_text, True, res_color)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 16
|
||||
y += 14
|
||||
shown += 1
|
||||
|
||||
if shown == 0:
|
||||
text = self.small_font.render("No items for sale", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
|
||||
return y
|
||||
|
||||
def _draw_controls_help(self, x: int, y: int) -> None:
|
||||
"""Draw controls help at bottom of panel."""
|
||||
def draw_bottom_bar(self, state: "SimulationState") -> None:
|
||||
"""Draw bottom information bar with event log."""
|
||||
bar_y = self.screen.get_height() - self.bottom_panel_height
|
||||
rect = pygame.Rect(0, bar_y, self.screen.get_width(), self.bottom_panel_height)
|
||||
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
Colors.PANEL_BORDER,
|
||||
(x - 5, y - 10),
|
||||
(self.screen.get_width() - 5, y - 10),
|
||||
self.screen, Colors.PANEL_BORDER,
|
||||
(0, bar_y), (self.screen.get_width(), bar_y),
|
||||
)
|
||||
|
||||
title = self.small_font.render("Controls", True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(title, (x, y))
|
||||
y += 20
|
||||
# Recent events (religious + diplomatic)
|
||||
x = 15
|
||||
y = bar_y + 8
|
||||
|
||||
controls = [
|
||||
"SPACE - Next Turn",
|
||||
"R - Reset Simulation",
|
||||
"M - Toggle Mode",
|
||||
"S - Settings",
|
||||
"ESC - Quit",
|
||||
]
|
||||
text = self.medium_font.render("Recent Events:", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
x += 120
|
||||
|
||||
for control in controls:
|
||||
text = self.small_font.render(control, True, Colors.TEXT_SECONDARY)
|
||||
# Show recent religious events
|
||||
for event in state.religious_events[:2]:
|
||||
event_type = event.get("type", "")
|
||||
desc = event.get("description", event_type)[:30]
|
||||
|
||||
if event_type == "conversion":
|
||||
color = Colors.RELIGIONS.get(event.get("to_religion", "atheist"), (150, 150, 150))
|
||||
elif event_type == "prayer":
|
||||
color = (180, 160, 220)
|
||||
else:
|
||||
color = Colors.TEXT_SECONDARY
|
||||
|
||||
text = self.small_font.render(f"✦ {desc}", True, color)
|
||||
self.screen.blit(text, (x, y))
|
||||
x += text.get_width() + 20
|
||||
|
||||
# Show recent diplomatic events
|
||||
for event in state.diplomatic_events[:2]:
|
||||
event_type = event.get("type", "")
|
||||
desc = event.get("description", event_type)[:30]
|
||||
|
||||
if "war" in event_type.lower():
|
||||
color = Colors.TEXT_DANGER
|
||||
icon = "⚔"
|
||||
elif "peace" in event_type.lower():
|
||||
color = Colors.TEXT_SUCCESS
|
||||
icon = "☮"
|
||||
else:
|
||||
color = Colors.TEXT_SECONDARY
|
||||
icon = "🏛"
|
||||
|
||||
text = self.small_font.render(f"{icon} {desc}", True, color)
|
||||
self.screen.blit(text, (x, y))
|
||||
x += text.get_width() + 20
|
||||
|
||||
# If no events, show placeholder
|
||||
if not state.religious_events and not state.diplomatic_events:
|
||||
text = self.small_font.render(
|
||||
"No recent events",
|
||||
True, Colors.TEXT_SECONDARY
|
||||
)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 16
|
||||
|
||||
def draw_connection_status(self, connected: bool) -> None:
|
||||
"""Draw connection status overlay when disconnected."""
|
||||
@ -220,20 +594,52 @@ class UIRenderer:
|
||||
|
||||
# Semi-transparent overlay
|
||||
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
||||
overlay.fill((0, 0, 0, 180))
|
||||
overlay.fill((0, 0, 0, 200))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
|
||||
# Connection message
|
||||
text = self.title_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
|
||||
text_rect = text.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2))
|
||||
# Connection box
|
||||
box_w, box_h = 400, 150
|
||||
box_x = (self.screen.get_width() - box_w) // 2
|
||||
box_y = (self.screen.get_height() - box_h) // 2
|
||||
|
||||
pygame.draw.rect(
|
||||
self.screen, Colors.PANEL_BG,
|
||||
(box_x, box_y, box_w, box_h), border_radius=10,
|
||||
)
|
||||
pygame.draw.rect(
|
||||
self.screen, Colors.PANEL_ACCENT,
|
||||
(box_x, box_y, box_w, box_h), 2, border_radius=10,
|
||||
)
|
||||
|
||||
# Pulsing dot
|
||||
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||
dot_color = (int(255 * pulse), int(180 * pulse), int(80 * pulse))
|
||||
pygame.draw.circle(
|
||||
self.screen, dot_color,
|
||||
(box_x + 30, box_y + 40), 8,
|
||||
)
|
||||
|
||||
# Text
|
||||
text = self.large_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
|
||||
text_rect = text.get_rect(center=(self.screen.get_width() // 2, box_y + 40))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
hint = self.small_font.render("Make sure the backend is running on localhost:8000", True, Colors.TEXT_SECONDARY)
|
||||
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2 + 30))
|
||||
hint = self.medium_font.render(
|
||||
"Make sure the backend is running on localhost:8000",
|
||||
True, Colors.TEXT_SECONDARY
|
||||
)
|
||||
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, box_y + 80))
|
||||
self.screen.blit(hint, hint_rect)
|
||||
|
||||
cmd = self.small_font.render(
|
||||
"Run: python -m backend.main",
|
||||
True, Colors.TEXT_HIGHLIGHT
|
||||
)
|
||||
cmd_rect = cmd.get_rect(center=(self.screen.get_width() // 2, box_y + 110))
|
||||
self.screen.blit(cmd, cmd_rect)
|
||||
|
||||
def draw(self, state: "SimulationState") -> None:
|
||||
"""Draw all UI elements."""
|
||||
self.draw_top_bar(state)
|
||||
self.draw_right_panel(state)
|
||||
|
||||
self.draw_bottom_bar(state)
|
||||
|
||||
83
tools/debug_diplomacy.py
Normal file
83
tools/debug_diplomacy.py
Normal file
@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Debug script for diplomacy relations."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.core.engine import GameEngine
|
||||
from backend.domain.diplomacy import FactionType, get_faction_relations, DiplomaticStatus
|
||||
|
||||
def main():
|
||||
print("Debugging diplomacy relations...\n")
|
||||
|
||||
engine = GameEngine()
|
||||
engine.initialize(50)
|
||||
|
||||
faction_relations = get_faction_relations()
|
||||
|
||||
print("Initial Relations:")
|
||||
factions = [f for f in FactionType if f != FactionType.NEUTRAL]
|
||||
for f1 in factions:
|
||||
for f2 in factions:
|
||||
if f1 != f2:
|
||||
rel = faction_relations.get_relation(f1, f2)
|
||||
status = faction_relations.get_status(f1, f2)
|
||||
print(f" {f1.value:12s} -> {f2.value:12s}: {rel:3d} ({status.value})")
|
||||
|
||||
# Run 50 turns and check relations
|
||||
print("\n\nRunning 50 turns...")
|
||||
for step in range(50):
|
||||
engine.next_step()
|
||||
|
||||
print("\nAfter 50 turns:")
|
||||
hostile_pairs = []
|
||||
for f1 in factions:
|
||||
for f2 in factions:
|
||||
if f1.value < f2.value: # Avoid duplicates
|
||||
rel = faction_relations.get_relation(f1, f2)
|
||||
status = faction_relations.get_status(f1, f2)
|
||||
marker = "⚔️" if status == DiplomaticStatus.HOSTILE else ""
|
||||
print(f" {f1.value:12s} <-> {f2.value:12s}: {rel:3d} ({status.value:8s}) {marker}")
|
||||
if status == DiplomaticStatus.HOSTILE:
|
||||
hostile_pairs.append((f1, f2, rel))
|
||||
|
||||
print(f"\nHostile pairs: {len(hostile_pairs)}")
|
||||
for f1, f2, rel in hostile_pairs:
|
||||
print(f" {f1.value} vs {f2.value}: {rel}")
|
||||
|
||||
# Run 50 more turns
|
||||
print("\n\nRunning 50 more turns...")
|
||||
for step in range(50):
|
||||
engine.next_step()
|
||||
|
||||
print("\nAfter 100 turns:")
|
||||
hostile_pairs = []
|
||||
war_pairs = []
|
||||
for f1 in factions:
|
||||
for f2 in factions:
|
||||
if f1.value < f2.value:
|
||||
rel = faction_relations.get_relation(f1, f2)
|
||||
status = faction_relations.get_status(f1, f2)
|
||||
if status == DiplomaticStatus.HOSTILE:
|
||||
hostile_pairs.append((f1, f2, rel))
|
||||
elif status == DiplomaticStatus.WAR:
|
||||
war_pairs.append((f1, f2, rel))
|
||||
print(f" {f1.value:12s} <-> {f2.value:12s}: {rel:3d} ({status.value:8s})")
|
||||
|
||||
print(f"\nHostile pairs: {len(hostile_pairs)}")
|
||||
print(f"War pairs: {len(war_pairs)}")
|
||||
|
||||
if war_pairs:
|
||||
print("\n🔥 WARS ACTIVE:")
|
||||
for f1, f2, rel in war_pairs:
|
||||
print(f" {f1.value} vs {f2.value}")
|
||||
|
||||
stats = engine.world.get_statistics()
|
||||
print(f"\nTotal wars declared: {stats.get('total_wars', 0)}")
|
||||
print(f"Active wars: {faction_relations.active_wars}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
738
tools/optimize_balance.py
Normal file
738
tools/optimize_balance.py
Normal file
@ -0,0 +1,738 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive Balance Optimizer for Village Simulation
|
||||
|
||||
This script runs simulations and optimizes config values for:
|
||||
- High survival rate (target: >50% at end)
|
||||
- Religion diversity (no single religion >60%)
|
||||
- Faction survival (all factions have living members)
|
||||
- Active market (trades happening, money circulating)
|
||||
- Oil industry activity (drilling and refining)
|
||||
|
||||
Usage:
|
||||
python tools/optimize_balance.py [--iterations 20] [--steps 1000]
|
||||
python tools/optimize_balance.py --quick-test
|
||||
python tools/optimize_balance.py --analyze-current
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add parent directory for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.config import get_config, reload_config
|
||||
from backend.core.engine import GameEngine
|
||||
from backend.core.logger import reset_simulation_logger
|
||||
from backend.domain.action import reset_action_config_cache
|
||||
from backend.domain.resources import reset_resource_cache
|
||||
|
||||
|
||||
@dataclass
|
||||
class BalanceMetrics:
|
||||
"""Comprehensive metrics for simulation balance."""
|
||||
total_turns: int = 0
|
||||
initial_population: int = 0
|
||||
final_population: int = 0
|
||||
|
||||
# Survival tracking
|
||||
deaths_by_cause: dict = field(default_factory=lambda: defaultdict(int))
|
||||
population_over_time: list = field(default_factory=list)
|
||||
|
||||
# Religion tracking
|
||||
religion_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||
conversions: int = 0
|
||||
|
||||
# Faction tracking
|
||||
faction_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||
wars_declared: int = 0
|
||||
peace_treaties: int = 0
|
||||
|
||||
# Market tracking
|
||||
total_listings: int = 0
|
||||
total_trades: int = 0
|
||||
trade_volume: int = 0
|
||||
trade_value: int = 0
|
||||
trades_by_resource: dict = field(default_factory=lambda: defaultdict(int))
|
||||
|
||||
# Action diversity
|
||||
action_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||
|
||||
# Oil industry
|
||||
oil_drilled: int = 0
|
||||
fuel_refined: int = 0
|
||||
|
||||
# Economy
|
||||
money_circulation: list = field(default_factory=list)
|
||||
avg_wealth: list = field(default_factory=list)
|
||||
wealth_gini: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def survival_rate(self) -> float:
|
||||
"""Final survival rate."""
|
||||
if self.initial_population == 0:
|
||||
return 0
|
||||
return self.final_population / self.initial_population
|
||||
|
||||
@property
|
||||
def religion_diversity(self) -> float:
|
||||
"""Religion diversity score (0-1, higher = more diverse)."""
|
||||
if not self.religion_counts:
|
||||
return 0
|
||||
total = sum(self.religion_counts.values())
|
||||
if total == 0:
|
||||
return 0
|
||||
max_count = max(self.religion_counts.values())
|
||||
# Perfect diversity = 20% each (5 religions), worst = 100% one religion
|
||||
return 1.0 - (max_count / total)
|
||||
|
||||
@property
|
||||
def dominant_religion_pct(self) -> float:
|
||||
"""Percentage held by dominant religion."""
|
||||
if not self.religion_counts:
|
||||
return 0
|
||||
total = sum(self.religion_counts.values())
|
||||
if total == 0:
|
||||
return 0
|
||||
return max(self.religion_counts.values()) / total
|
||||
|
||||
@property
|
||||
def factions_alive(self) -> int:
|
||||
"""Number of factions with living members."""
|
||||
return len([f for f, c in self.faction_counts.items() if c > 0])
|
||||
|
||||
@property
|
||||
def faction_diversity(self) -> float:
|
||||
"""Faction diversity (0-1)."""
|
||||
if not self.faction_counts:
|
||||
return 0
|
||||
alive = self.factions_alive
|
||||
# We have 5 non-neutral factions
|
||||
return alive / 5.0
|
||||
|
||||
@property
|
||||
def market_activity(self) -> float:
|
||||
"""Market activity score."""
|
||||
if self.total_turns == 0:
|
||||
return 0
|
||||
trades_per_turn = self.total_trades / self.total_turns
|
||||
# Target: 0.3 trades per turn per 10 agents
|
||||
return min(1.0, trades_per_turn / 0.3)
|
||||
|
||||
@property
|
||||
def trade_diversity(self) -> float:
|
||||
"""How many different resources are being traded."""
|
||||
resources_traded = len([r for r, c in self.trades_by_resource.items() if c > 0])
|
||||
return resources_traded / 6.0 # 6 tradeable resources
|
||||
|
||||
@property
|
||||
def oil_industry_activity(self) -> float:
|
||||
"""Oil industry health score."""
|
||||
total_oil_ops = self.oil_drilled + self.fuel_refined
|
||||
# Target: 5% of actions should be oil-related
|
||||
total_actions = sum(self.action_counts.values())
|
||||
if total_actions == 0:
|
||||
return 0
|
||||
return min(1.0, (total_oil_ops / total_actions) / 0.05)
|
||||
|
||||
@property
|
||||
def economy_health(self) -> float:
|
||||
"""Overall economy health."""
|
||||
if not self.avg_wealth:
|
||||
return 0
|
||||
final_wealth = self.avg_wealth[-1]
|
||||
# Target: average wealth should stay above 50
|
||||
return min(1.0, final_wealth / 50)
|
||||
|
||||
def score(self) -> float:
|
||||
"""Calculate overall balance score (0-100)."""
|
||||
score = 0
|
||||
|
||||
# Survival rate (0-30 points) - CRITICAL
|
||||
# Target: at least 30% survival
|
||||
survival_score = min(30, self.survival_rate * 100)
|
||||
score += survival_score
|
||||
|
||||
# Religion diversity (0-15 points)
|
||||
# Target: no single religion > 50%
|
||||
religion_score = self.religion_diversity * 15
|
||||
score += religion_score
|
||||
|
||||
# Faction survival (0-15 points)
|
||||
# Target: at least 4 of 5 factions alive
|
||||
faction_score = self.faction_diversity * 15
|
||||
score += faction_score
|
||||
|
||||
# Market activity (0-15 points)
|
||||
market_score = self.market_activity * 15
|
||||
score += market_score
|
||||
|
||||
# Trade diversity (0-10 points)
|
||||
trade_div_score = self.trade_diversity * 10
|
||||
score += trade_div_score
|
||||
|
||||
# Oil industry (0-10 points)
|
||||
oil_score = self.oil_industry_activity * 10
|
||||
score += oil_score
|
||||
|
||||
# Economy health (0-5 points)
|
||||
econ_score = self.economy_health * 5
|
||||
score += econ_score
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def run_simulation(config_overrides: dict, num_steps: int = 1000, num_agents: int = 100) -> BalanceMetrics:
|
||||
"""Run a simulation with custom config and return metrics."""
|
||||
# Apply config overrides
|
||||
config_path = Path("config.json")
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Deep merge overrides
|
||||
for section, values in config_overrides.items():
|
||||
if section in config:
|
||||
config[section].update(values)
|
||||
else:
|
||||
config[section] = values
|
||||
|
||||
# Save temp config
|
||||
temp_config = Path("config_temp.json")
|
||||
with open(temp_config, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
# Reload config
|
||||
reload_config(str(temp_config))
|
||||
reset_action_config_cache()
|
||||
reset_resource_cache()
|
||||
|
||||
# Initialize engine - need to set initial_agents BEFORE reset() calls initialize()
|
||||
GameEngine._instance = None # Reset singleton
|
||||
engine = GameEngine()
|
||||
# Note: reset() already calls world.initialize(), so we must set initial_agents first
|
||||
# Get the config and modify it before reset
|
||||
sim_config = get_config()
|
||||
engine.world.config.initial_agents = num_agents
|
||||
# Reset creates a new world and initializes it
|
||||
from backend.core.world import World, WorldConfig
|
||||
world_config = WorldConfig(initial_agents=num_agents)
|
||||
engine.reset(config=world_config)
|
||||
|
||||
# Suppress logging
|
||||
import logging
|
||||
logging.getLogger("simulation").setLevel(logging.ERROR)
|
||||
|
||||
metrics = BalanceMetrics()
|
||||
metrics.initial_population = num_agents
|
||||
|
||||
# Run simulation
|
||||
for step in range(num_steps):
|
||||
if not engine.is_running:
|
||||
break
|
||||
|
||||
turn_log = engine.next_step()
|
||||
metrics.total_turns += 1
|
||||
|
||||
# Track population
|
||||
living = len(engine.world.get_living_agents())
|
||||
metrics.population_over_time.append(living)
|
||||
|
||||
# Track money
|
||||
agents = engine.world.get_living_agents()
|
||||
if agents:
|
||||
total_money = sum(a.money for a in agents)
|
||||
avg_money = total_money / len(agents)
|
||||
metrics.money_circulation.append(total_money)
|
||||
metrics.avg_wealth.append(avg_money)
|
||||
|
||||
# Gini coefficient
|
||||
moneys = sorted([a.money for a in agents])
|
||||
n = len(moneys)
|
||||
if n > 1 and total_money > 0:
|
||||
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
||||
gini = sum_of_diffs / (2 * n * total_money)
|
||||
else:
|
||||
gini = 0
|
||||
metrics.wealth_gini.append(gini)
|
||||
|
||||
# Process actions
|
||||
for action_data in turn_log.agent_actions:
|
||||
decision = action_data.get("decision", {})
|
||||
result = action_data.get("result", {})
|
||||
action_type = decision.get("action", "unknown")
|
||||
|
||||
metrics.action_counts[action_type] += 1
|
||||
|
||||
# Track specific actions
|
||||
if action_type == "drill_oil" and result.get("success"):
|
||||
for res in result.get("resources_gained", []):
|
||||
if res.get("type") == "oil":
|
||||
metrics.oil_drilled += res.get("quantity", 0)
|
||||
|
||||
elif action_type == "refine" and result.get("success"):
|
||||
for res in result.get("resources_gained", []):
|
||||
if res.get("type") == "fuel":
|
||||
metrics.fuel_refined += res.get("quantity", 0)
|
||||
|
||||
elif action_type == "preach" and result.get("success"):
|
||||
if "converted" in result.get("message", "").lower():
|
||||
metrics.conversions += 1
|
||||
|
||||
elif action_type == "declare_war" and result.get("success"):
|
||||
metrics.wars_declared += 1
|
||||
|
||||
elif action_type == "make_peace" and result.get("success"):
|
||||
metrics.peace_treaties += 1
|
||||
|
||||
elif action_type == "trade" and result.get("success"):
|
||||
message = result.get("message", "")
|
||||
if "Listed" in message:
|
||||
metrics.total_listings += 1
|
||||
elif "Bought" in message:
|
||||
match = re.search(r"Bought (\d+) (\w+) for (\d+)c", message)
|
||||
if match:
|
||||
qty = int(match.group(1))
|
||||
res = match.group(2)
|
||||
value = int(match.group(3))
|
||||
metrics.total_trades += 1
|
||||
metrics.trade_volume += qty
|
||||
metrics.trade_value += value
|
||||
metrics.trades_by_resource[res] += 1
|
||||
|
||||
# Process deaths
|
||||
for death_name in turn_log.deaths:
|
||||
for agent in engine.world.agents:
|
||||
if agent.name == death_name and agent.death_reason:
|
||||
metrics.deaths_by_cause[agent.death_reason] += 1
|
||||
break
|
||||
|
||||
# Collect final stats
|
||||
living_agents = engine.world.get_living_agents()
|
||||
metrics.final_population = len(living_agents)
|
||||
|
||||
# Count religions and factions
|
||||
for agent in living_agents:
|
||||
metrics.religion_counts[agent.religion.religion.value] += 1
|
||||
metrics.faction_counts[agent.diplomacy.faction.value] += 1
|
||||
|
||||
# Cleanup
|
||||
engine.logger.close()
|
||||
temp_config.unlink(missing_ok=True)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def generate_balanced_config() -> dict:
|
||||
"""Generate a config focused on balance."""
|
||||
return {
|
||||
"agent_stats": {
|
||||
"start_hunger": random.randint(85, 95),
|
||||
"start_thirst": random.randint(85, 95),
|
||||
"hunger_decay": random.randint(1, 2),
|
||||
"thirst_decay": random.randint(1, 3),
|
||||
"heat_decay": random.randint(1, 2),
|
||||
"faith_decay": random.randint(1, 2),
|
||||
"critical_threshold": round(random.uniform(0.12, 0.20), 2),
|
||||
},
|
||||
"resources": {
|
||||
"meat_hunger": random.randint(40, 55),
|
||||
"berries_hunger": random.randint(12, 18),
|
||||
"water_thirst": random.randint(50, 70),
|
||||
"fire_heat": random.randint(25, 40),
|
||||
},
|
||||
"actions": {
|
||||
"hunt_success": round(random.uniform(0.75, 0.90), 2),
|
||||
"drill_oil_success": round(random.uniform(0.70, 0.85), 2),
|
||||
"hunt_meat_min": random.randint(3, 4),
|
||||
"hunt_meat_max": random.randint(5, 7),
|
||||
"gather_min": random.randint(4, 5),
|
||||
"gather_max": random.randint(6, 8),
|
||||
# preach_convert_chance is in actions, not religion
|
||||
"preach_convert_chance": round(random.uniform(0.03, 0.08), 2),
|
||||
},
|
||||
"religion": {
|
||||
"conversion_resistance": round(random.uniform(0.65, 0.85), 2),
|
||||
"zealot_threshold": round(random.uniform(0.80, 0.92), 2),
|
||||
"same_religion_bonus": round(random.uniform(0.08, 0.15), 2),
|
||||
"different_religion_penalty": round(random.uniform(0.02, 0.06), 2),
|
||||
},
|
||||
"diplomacy": {
|
||||
"starting_relations": random.randint(55, 70),
|
||||
"relation_decay": random.randint(0, 1),
|
||||
"trade_relation_boost": random.randint(6, 10),
|
||||
"war_exhaustion_rate": random.randint(8, 15),
|
||||
"war_threshold": random.randint(15, 25),
|
||||
},
|
||||
"economy": {
|
||||
"buy_efficiency_threshold": round(random.uniform(0.80, 0.95), 2),
|
||||
"min_wealth_target": random.randint(25, 50),
|
||||
"max_price_markup": round(random.uniform(1.4, 1.8), 1),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def mutate_config(config: dict, mutation_rate: float = 0.3) -> dict:
|
||||
"""Mutate a configuration."""
|
||||
new_config = json.loads(json.dumps(config))
|
||||
|
||||
for section, values in new_config.items():
|
||||
for key, value in values.items():
|
||||
if random.random() < mutation_rate:
|
||||
if isinstance(value, int):
|
||||
delta = max(1, abs(value) // 4)
|
||||
new_config[section][key] = max(0, value + random.randint(-delta, delta))
|
||||
elif isinstance(value, float):
|
||||
delta = abs(value) * 0.15
|
||||
new_val = value + random.uniform(-delta, delta)
|
||||
new_config[section][key] = round(max(0.01, min(0.99, new_val)), 2)
|
||||
|
||||
return new_config
|
||||
|
||||
|
||||
def crossover_configs(config1: dict, config2: dict) -> dict:
|
||||
"""Crossover two configurations."""
|
||||
new_config = {}
|
||||
for section in set(config1.keys()) | set(config2.keys()):
|
||||
if section in config1 and section in config2:
|
||||
new_config[section] = {}
|
||||
for key in set(config1[section].keys()) | set(config2[section].keys()):
|
||||
if random.random() < 0.5 and key in config1[section]:
|
||||
new_config[section][key] = config1[section][key]
|
||||
elif key in config2[section]:
|
||||
new_config[section][key] = config2[section][key]
|
||||
elif section in config1:
|
||||
new_config[section] = config1[section].copy()
|
||||
else:
|
||||
new_config[section] = config2[section].copy()
|
||||
return new_config
|
||||
|
||||
|
||||
def print_metrics(metrics: BalanceMetrics, detailed: bool = True):
|
||||
"""Print metrics in a readable format."""
|
||||
print(f"\n 📊 Balance Score: {metrics.score():.1f}/100")
|
||||
print(f" ├─ Survival: {metrics.survival_rate*100:.0f}% ({metrics.final_population}/{metrics.initial_population})")
|
||||
print(f" ├─ Religion: {metrics.religion_diversity*100:.0f}% diversity (dominant: {metrics.dominant_religion_pct*100:.0f}%)")
|
||||
print(f" ├─ Factions: {metrics.factions_alive}/5 alive ({metrics.faction_diversity*100:.0f}%)")
|
||||
print(f" ├─ Market: {metrics.total_trades} trades, {metrics.total_listings} listings")
|
||||
print(f" ├─ Trade diversity: {metrics.trade_diversity*100:.0f}%")
|
||||
print(f" ├─ Oil industry: {metrics.oil_drilled} oil, {metrics.fuel_refined} fuel")
|
||||
print(f" └─ Economy: avg wealth ${metrics.avg_wealth[-1]:.0f}" if metrics.avg_wealth else " └─ Economy: N/A")
|
||||
|
||||
if detailed:
|
||||
print(f"\n 📋 Death causes:")
|
||||
for cause, count in sorted(metrics.deaths_by_cause.items(), key=lambda x: -x[1])[:5]:
|
||||
print(f" - {cause}: {count}")
|
||||
|
||||
print(f"\n 🏛️ Religions:")
|
||||
for religion, count in sorted(metrics.religion_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" - {religion}: {count}")
|
||||
|
||||
print(f"\n ⚔️ Factions:")
|
||||
for faction, count in sorted(metrics.faction_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" - {faction}: {count}")
|
||||
|
||||
|
||||
def optimize_balance(iterations: int = 20, steps_per_sim: int = 1000, population_size: int = 8):
|
||||
"""Run genetic optimization for balance."""
|
||||
print("\n" + "=" * 70)
|
||||
print("🧬 BALANCE OPTIMIZER - Finding Optimal Configuration")
|
||||
print("=" * 70)
|
||||
print(f" Iterations: {iterations}")
|
||||
print(f" Steps per simulation: {steps_per_sim}")
|
||||
print(f" Population size: {population_size}")
|
||||
print(f" Agents per simulation: 100")
|
||||
print("=" * 70)
|
||||
|
||||
# Create initial population
|
||||
population = []
|
||||
|
||||
# Start with a well-balanced baseline
|
||||
baseline = {
|
||||
"agent_stats": {
|
||||
"start_hunger": 92,
|
||||
"start_thirst": 92,
|
||||
"hunger_decay": 1,
|
||||
"thirst_decay": 2,
|
||||
"heat_decay": 1,
|
||||
"faith_decay": 1,
|
||||
"critical_threshold": 0.15,
|
||||
},
|
||||
"resources": {
|
||||
"meat_hunger": 50,
|
||||
"berries_hunger": 15,
|
||||
"water_thirst": 65,
|
||||
"fire_heat": 35,
|
||||
},
|
||||
"actions": {
|
||||
"hunt_success": 0.85,
|
||||
"drill_oil_success": 0.80,
|
||||
"hunt_meat_min": 4,
|
||||
"hunt_meat_max": 6,
|
||||
"gather_min": 4,
|
||||
"gather_max": 7,
|
||||
"preach_convert_chance": 0.05,
|
||||
},
|
||||
"religion": {
|
||||
"conversion_resistance": 0.75,
|
||||
"zealot_threshold": 0.88,
|
||||
"same_religion_bonus": 0.10,
|
||||
"different_religion_penalty": 0.03,
|
||||
},
|
||||
"diplomacy": {
|
||||
"starting_relations": 65,
|
||||
"relation_decay": 0,
|
||||
"trade_relation_boost": 8,
|
||||
"war_exhaustion_rate": 12,
|
||||
"war_threshold": 18,
|
||||
},
|
||||
"economy": {
|
||||
"buy_efficiency_threshold": 0.88,
|
||||
"min_wealth_target": 35,
|
||||
"max_price_markup": 1.5,
|
||||
},
|
||||
}
|
||||
population.append(baseline)
|
||||
|
||||
# Add survival-focused variant
|
||||
survival_focused = json.loads(json.dumps(baseline))
|
||||
survival_focused["agent_stats"]["hunger_decay"] = 1
|
||||
survival_focused["agent_stats"]["thirst_decay"] = 1
|
||||
survival_focused["resources"]["meat_hunger"] = 55
|
||||
survival_focused["resources"]["berries_hunger"] = 18
|
||||
survival_focused["resources"]["water_thirst"] = 70
|
||||
population.append(survival_focused)
|
||||
|
||||
# Add religion-balanced variant
|
||||
religion_balanced = json.loads(json.dumps(baseline))
|
||||
religion_balanced["religion"]["conversion_resistance"] = 0.82
|
||||
religion_balanced["actions"]["preach_convert_chance"] = 0.03
|
||||
religion_balanced["religion"]["zealot_threshold"] = 0.92
|
||||
population.append(religion_balanced)
|
||||
|
||||
# Add diplomacy-stable variant
|
||||
diplomacy_stable = json.loads(json.dumps(baseline))
|
||||
diplomacy_stable["diplomacy"]["relation_decay"] = 0
|
||||
diplomacy_stable["diplomacy"]["starting_relations"] = 70
|
||||
diplomacy_stable["diplomacy"]["war_exhaustion_rate"] = 15
|
||||
population.append(diplomacy_stable)
|
||||
|
||||
# Fill rest with random
|
||||
while len(population) < population_size:
|
||||
population.append(generate_balanced_config())
|
||||
|
||||
best_config = None
|
||||
best_score = 0
|
||||
best_metrics = None
|
||||
|
||||
for gen in range(iterations):
|
||||
print(f"\n📍 Generation {gen + 1}/{iterations}")
|
||||
print("-" * 50)
|
||||
|
||||
scored_population = []
|
||||
for i, config in enumerate(population):
|
||||
sys.stdout.write(f"\r Evaluating config {i + 1}/{len(population)}...")
|
||||
sys.stdout.flush()
|
||||
|
||||
metrics = run_simulation(config, steps_per_sim, num_agents=100)
|
||||
score = metrics.score()
|
||||
scored_population.append((config, metrics, score))
|
||||
|
||||
# Sort by score
|
||||
scored_population.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
# Print top results
|
||||
print(f"\r Top configs this generation:")
|
||||
for i, (config, metrics, score) in enumerate(scored_population[:3]):
|
||||
print(f"\n #{i + 1}: Score {score:.1f}")
|
||||
print_metrics(metrics, detailed=False)
|
||||
|
||||
# Track best overall
|
||||
if scored_population[0][2] > best_score:
|
||||
best_config = scored_population[0][0]
|
||||
best_score = scored_population[0][2]
|
||||
best_metrics = scored_population[0][1]
|
||||
print(f"\n ⭐ New best score: {best_score:.1f}")
|
||||
|
||||
# Create next generation
|
||||
new_population = []
|
||||
|
||||
# Keep top 2 (elitism)
|
||||
new_population.append(scored_population[0][0])
|
||||
new_population.append(scored_population[1][0])
|
||||
|
||||
# Crossover and mutate
|
||||
while len(new_population) < population_size:
|
||||
parent1 = random.choice(scored_population[:4])[0]
|
||||
parent2 = random.choice(scored_population[:4])[0]
|
||||
child = crossover_configs(parent1, parent2)
|
||||
child = mutate_config(child, mutation_rate=0.25)
|
||||
new_population.append(child)
|
||||
|
||||
population = new_population
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("🏆 OPTIMIZATION COMPLETE")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n Best Score: {best_score:.1f}/100")
|
||||
print_metrics(best_metrics, detailed=True)
|
||||
|
||||
print("\n 📝 Best Configuration:")
|
||||
print("-" * 50)
|
||||
print(json.dumps(best_config, indent=2))
|
||||
|
||||
# Save optimized config
|
||||
output_path = Path("config_balanced.json")
|
||||
|
||||
with open("config.json") as f:
|
||||
full_config = json.load(f)
|
||||
|
||||
for section, values in best_config.items():
|
||||
if section in full_config:
|
||||
full_config[section].update(values)
|
||||
else:
|
||||
full_config[section] = values
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(full_config, f, indent=2)
|
||||
|
||||
print(f"\n ✅ Saved optimized config to: {output_path}")
|
||||
print(" To apply: cp config_balanced.json config.json")
|
||||
|
||||
return best_config, best_metrics
|
||||
|
||||
|
||||
def analyze_current_config(steps: int = 500):
|
||||
"""Analyze the current configuration."""
|
||||
print("\n" + "=" * 70)
|
||||
print("📊 ANALYZING CURRENT CONFIGURATION")
|
||||
print("=" * 70)
|
||||
|
||||
metrics = run_simulation({}, steps, num_agents=100)
|
||||
print_metrics(metrics, detailed=True)
|
||||
|
||||
# Provide recommendations
|
||||
print("\n" + "=" * 70)
|
||||
print("💡 RECOMMENDATIONS")
|
||||
print("=" * 70)
|
||||
|
||||
if metrics.survival_rate < 0.3:
|
||||
print("\n ⚠️ LOW SURVIVAL RATE")
|
||||
print(" - Reduce hunger_decay and thirst_decay")
|
||||
print(" - Increase food resource values (meat_hunger, berries_hunger)")
|
||||
print(" - Lower critical_threshold")
|
||||
|
||||
if metrics.dominant_religion_pct > 0.6:
|
||||
print("\n ⚠️ RELIGION DOMINANCE")
|
||||
print(" - Increase conversion_resistance (try 0.75+)")
|
||||
print(" - Reduce preach_convert_chance (try 0.05)")
|
||||
print(" - Increase zealot_threshold (try 0.88+)")
|
||||
|
||||
if metrics.factions_alive < 4:
|
||||
print("\n ⚠️ FACTIONS DYING OUT")
|
||||
print(" - Set relation_decay to 0 or 1")
|
||||
print(" - Increase starting_relations (try 65+)")
|
||||
print(" - Increase war_exhaustion_rate (try 10+)")
|
||||
|
||||
if metrics.total_trades < metrics.total_turns * 0.1:
|
||||
print("\n ⚠️ LOW MARKET ACTIVITY")
|
||||
print(" - Increase buy_efficiency_threshold (try 0.9)")
|
||||
print(" - Lower min_wealth_target")
|
||||
print(" - Reduce max_price_markup")
|
||||
|
||||
if metrics.oil_drilled + metrics.fuel_refined < 50:
|
||||
print("\n ⚠️ LOW OIL INDUSTRY")
|
||||
print(" - Increase drill_oil_success (try 0.80)")
|
||||
print(" - Check that factions with oil bonus survive")
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def quick_test(steps: int = 500):
|
||||
"""Quick test with a balanced preset."""
|
||||
print("\n🧪 Quick Test with Balanced Preset")
|
||||
print("-" * 50)
|
||||
|
||||
test_config = {
|
||||
"agent_stats": {
|
||||
"start_hunger": 92,
|
||||
"start_thirst": 92,
|
||||
"hunger_decay": 1,
|
||||
"thirst_decay": 2,
|
||||
"heat_decay": 1,
|
||||
"faith_decay": 1,
|
||||
"critical_threshold": 0.15,
|
||||
},
|
||||
"resources": {
|
||||
"meat_hunger": 50,
|
||||
"berries_hunger": 16,
|
||||
"water_thirst": 65,
|
||||
"fire_heat": 35,
|
||||
},
|
||||
"actions": {
|
||||
"hunt_success": 0.85,
|
||||
"drill_oil_success": 0.80,
|
||||
"hunt_meat_min": 4,
|
||||
"hunt_meat_max": 6,
|
||||
"gather_min": 4,
|
||||
"gather_max": 7,
|
||||
"preach_convert_chance": 0.04,
|
||||
},
|
||||
"religion": {
|
||||
"conversion_resistance": 0.78,
|
||||
"zealot_threshold": 0.90,
|
||||
"same_religion_bonus": 0.10,
|
||||
"different_religion_penalty": 0.03,
|
||||
},
|
||||
"diplomacy": {
|
||||
"starting_relations": 65,
|
||||
"relation_decay": 0,
|
||||
"trade_relation_boost": 8,
|
||||
"war_exhaustion_rate": 12,
|
||||
"war_threshold": 18,
|
||||
},
|
||||
"economy": {
|
||||
"buy_efficiency_threshold": 0.90,
|
||||
"min_wealth_target": 30,
|
||||
"max_price_markup": 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
print("\n Testing config:")
|
||||
print(json.dumps(test_config, indent=2))
|
||||
|
||||
metrics = run_simulation(test_config, steps, num_agents=100)
|
||||
print_metrics(metrics, detailed=True)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Optimize Village Simulation balance")
|
||||
parser.add_argument("--iterations", "-i", type=int, default=15, help="Optimization iterations")
|
||||
parser.add_argument("--steps", "-s", type=int, default=800, help="Steps per simulation")
|
||||
parser.add_argument("--population", "-p", type=int, default=8, help="Population size for GA")
|
||||
parser.add_argument("--quick-test", "-q", action="store_true", help="Quick test balanced preset")
|
||||
parser.add_argument("--analyze-current", "-a", action="store_true", help="Analyze current config")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.quick_test:
|
||||
quick_test(args.steps)
|
||||
elif args.analyze_current:
|
||||
analyze_current_config(args.steps)
|
||||
else:
|
||||
optimize_balance(args.iterations, args.steps, args.population)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
238
tools/test_religion_diplomacy.py
Normal file
238
tools/test_religion_diplomacy.py
Normal file
@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Religion and Diplomacy features.
|
||||
|
||||
Verifies that agents are spawned with diverse religions and factions,
|
||||
and that the systems work correctly.
|
||||
|
||||
Usage:
|
||||
python tools/test_religion_diplomacy.py [--steps 100]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.config import get_config
|
||||
from backend.core.engine import GameEngine
|
||||
|
||||
|
||||
def test_agent_diversity(num_agents: int = 50, num_steps: int = 100):
|
||||
"""Test that agents have diverse religions and factions."""
|
||||
print("\n" + "=" * 70)
|
||||
print(" RELIGION & DIPLOMACY SYSTEM TEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Initialize engine
|
||||
engine = GameEngine()
|
||||
engine.initialize(num_agents)
|
||||
|
||||
# Analyze agent distribution
|
||||
religion_counts = defaultdict(int)
|
||||
faction_counts = defaultdict(int)
|
||||
faith_levels = []
|
||||
|
||||
agents = engine.world.agents
|
||||
|
||||
print(f"\n📊 Initial Agent Distribution ({len(agents)} agents):")
|
||||
print("-" * 50)
|
||||
|
||||
for agent in agents:
|
||||
religion = agent.religion.religion.value
|
||||
faction = agent.diplomacy.faction.value
|
||||
religion_counts[religion] += 1
|
||||
faction_counts[faction] += 1
|
||||
faith_levels.append(agent.religion.faith)
|
||||
|
||||
print("\n🕯️ RELIGIONS:")
|
||||
for religion, count in sorted(religion_counts.items(), key=lambda x: -x[1]):
|
||||
pct = count / len(agents) * 100
|
||||
bar = "█" * int(pct / 5)
|
||||
print(f" {religion:12s}: {count:3d} ({pct:5.1f}%) {bar}")
|
||||
|
||||
print("\n⚔️ FACTIONS:")
|
||||
for faction, count in sorted(faction_counts.items(), key=lambda x: -x[1]):
|
||||
pct = count / len(agents) * 100
|
||||
bar = "█" * int(pct / 5)
|
||||
print(f" {faction:12s}: {count:3d} ({pct:5.1f}%) {bar}")
|
||||
|
||||
avg_faith = sum(faith_levels) / len(faith_levels) if faith_levels else 0
|
||||
print(f"\n✨ Average Faith: {avg_faith:.1f}")
|
||||
|
||||
# Check for issues
|
||||
issues = []
|
||||
|
||||
atheist_pct = religion_counts.get("atheist", 0) / len(agents) * 100
|
||||
if atheist_pct > 50:
|
||||
issues.append(f"⚠️ Too many atheists: {atheist_pct:.1f}% (expected < 50%)")
|
||||
|
||||
neutral_pct = faction_counts.get("neutral", 0) / len(agents) * 100
|
||||
if neutral_pct > 30:
|
||||
issues.append(f"⚠️ Too many neutral faction: {neutral_pct:.1f}% (expected < 30%)")
|
||||
|
||||
if len(religion_counts) < 3:
|
||||
issues.append(f"⚠️ Low religion diversity: only {len(religion_counts)} religions")
|
||||
|
||||
if len(faction_counts) < 3:
|
||||
issues.append(f"⚠️ Low faction diversity: only {len(faction_counts)} factions")
|
||||
|
||||
if issues:
|
||||
print("\n⚠️ ISSUES FOUND:")
|
||||
for issue in issues:
|
||||
print(f" {issue}")
|
||||
else:
|
||||
print("\n✅ Distribution looks good!")
|
||||
|
||||
# Run simulation to test mechanics
|
||||
print("\n" + "=" * 70)
|
||||
print(f" Running {num_steps} step simulation...")
|
||||
print("=" * 70)
|
||||
|
||||
# Track events
|
||||
religious_events = []
|
||||
diplomatic_events = []
|
||||
faith_changes = []
|
||||
|
||||
initial_faith = {a.id: a.religion.faith for a in agents}
|
||||
|
||||
for step in range(num_steps):
|
||||
turn_log = engine.next_step()
|
||||
|
||||
# Collect events
|
||||
religious_events.extend(turn_log.religious_events)
|
||||
diplomatic_events.extend(turn_log.diplomatic_events)
|
||||
|
||||
if step % 20 == 19:
|
||||
sys.stdout.write(f"\r Step {step + 1}/{num_steps}...")
|
||||
sys.stdout.flush()
|
||||
|
||||
print(f"\r Completed {num_steps} steps! ")
|
||||
|
||||
# Final analysis
|
||||
print("\n📈 SIMULATION RESULTS:")
|
||||
print("-" * 50)
|
||||
|
||||
living_agents = engine.world.get_living_agents()
|
||||
print(f" Living agents: {len(living_agents)}/{len(agents)}")
|
||||
|
||||
# Final faith levels
|
||||
final_faith = [a.religion.faith for a in living_agents]
|
||||
avg_final_faith = sum(final_faith) / len(final_faith) if final_faith else 0
|
||||
print(f" Average faith: {avg_final_faith:.1f} (started: {avg_faith:.1f})")
|
||||
|
||||
# Events summary
|
||||
print(f"\n Religious events: {len(religious_events)}")
|
||||
if religious_events:
|
||||
event_types = defaultdict(int)
|
||||
for event in religious_events:
|
||||
event_types[event.get("type", "unknown")] += 1
|
||||
for event_type, count in sorted(event_types.items(), key=lambda x: -x[1])[:5]:
|
||||
print(f" - {event_type}: {count}")
|
||||
|
||||
print(f"\n Diplomatic events: {len(diplomatic_events)}")
|
||||
if diplomatic_events:
|
||||
event_types = defaultdict(int)
|
||||
for event in diplomatic_events:
|
||||
event_types[event.get("type", "unknown")] += 1
|
||||
for event_type, count in sorted(event_types.items(), key=lambda x: -x[1])[:5]:
|
||||
print(f" - {event_type}: {count}")
|
||||
|
||||
# Check world state
|
||||
stats = engine.world.get_statistics()
|
||||
print(f"\n Total wars declared: {stats.get('total_wars', 0)}")
|
||||
print(f" Total peace treaties: {stats.get('total_peace_treaties', 0)}")
|
||||
print(f" Active wars: {stats.get('active_wars', [])}")
|
||||
|
||||
# Final religion/faction distribution
|
||||
print("\n📊 Final Religion Distribution:")
|
||||
final_religions = defaultdict(int)
|
||||
for agent in living_agents:
|
||||
final_religions[agent.religion.religion.value] += 1
|
||||
|
||||
for religion, count in sorted(final_religions.items(), key=lambda x: -x[1]):
|
||||
pct = count / len(living_agents) * 100 if living_agents else 0
|
||||
print(f" {religion:12s}: {count:3d} ({pct:5.1f}%)")
|
||||
|
||||
# Return metrics for optimization
|
||||
return {
|
||||
"initial_atheist_pct": atheist_pct,
|
||||
"initial_neutral_pct": neutral_pct,
|
||||
"religion_diversity": len(religion_counts),
|
||||
"faction_diversity": len(faction_counts),
|
||||
"avg_initial_faith": avg_faith,
|
||||
"avg_final_faith": avg_final_faith,
|
||||
"religious_events": len(religious_events),
|
||||
"diplomatic_events": len(diplomatic_events),
|
||||
"survival_rate": len(living_agents) / len(agents) if agents else 0,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Test Religion & Diplomacy systems")
|
||||
parser.add_argument("--agents", "-a", type=int, default=50, help="Number of agents")
|
||||
parser.add_argument("--steps", "-s", type=int, default=100, help="Simulation steps")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
metrics = test_agent_diversity(args.agents, args.steps)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" TEST COMPLETE")
|
||||
print("=" * 70)
|
||||
|
||||
# Final verdict
|
||||
score = 0
|
||||
max_score = 6
|
||||
|
||||
if metrics["initial_atheist_pct"] < 30:
|
||||
score += 1
|
||||
print("✅ Atheist percentage is reasonable")
|
||||
else:
|
||||
print(f"❌ Too many atheists: {metrics['initial_atheist_pct']:.1f}%")
|
||||
|
||||
if metrics["initial_neutral_pct"] < 20:
|
||||
score += 1
|
||||
print("✅ Neutral faction percentage is reasonable")
|
||||
else:
|
||||
print(f"❌ Too many neutrals: {metrics['initial_neutral_pct']:.1f}%")
|
||||
|
||||
if metrics["religion_diversity"] >= 4:
|
||||
score += 1
|
||||
print(f"✅ Good religion diversity: {metrics['religion_diversity']} religions")
|
||||
else:
|
||||
print(f"❌ Low religion diversity: {metrics['religion_diversity']} religions")
|
||||
|
||||
if metrics["faction_diversity"] >= 4:
|
||||
score += 1
|
||||
print(f"✅ Good faction diversity: {metrics['faction_diversity']} factions")
|
||||
else:
|
||||
print(f"❌ Low faction diversity: {metrics['faction_diversity']} factions")
|
||||
|
||||
if metrics["religious_events"] > 0:
|
||||
score += 1
|
||||
print(f"✅ Religious events occurring: {metrics['religious_events']}")
|
||||
else:
|
||||
print("❌ No religious events")
|
||||
|
||||
if metrics["diplomatic_events"] > 0 or metrics["religious_events"] > 0:
|
||||
score += 1
|
||||
print(f"✅ Social dynamics active")
|
||||
else:
|
||||
print("❌ No social dynamics")
|
||||
|
||||
print(f"\n📊 Score: {score}/{max_score}")
|
||||
|
||||
if score < max_score:
|
||||
print("\n💡 Consider adjusting config.json parameters for better diversity")
|
||||
|
||||
return score == max_score
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user