From cfd6c87f869b2f3a5ced8e68cdceb200705b9393 Mon Sep 17 00:00:00 2001 From: elit3guzhva Date: Mon, 19 Jan 2026 02:14:46 +0300 Subject: [PATCH] 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. --- backend/api/schemas.py | 27 + backend/config.py | 132 +++-- backend/core/ai.py | 783 ++++++++++++++----------- backend/core/engine.py | 426 ++++++++++---- backend/core/world.py | 214 ++++++- backend/domain/__init__.py | 10 +- backend/domain/action.py | 102 +++- backend/domain/agent.py | 165 ++++-- backend/domain/diplomacy.py | 515 ++++++++++++++++ backend/domain/religion.py | 337 +++++++++++ backend/domain/resources.py | 64 +- config.json | 137 +++-- frontend/client.py | 90 ++- frontend/main.py | 122 ++-- frontend/renderer/__init__.py | 9 +- frontend/renderer/agent_renderer.py | 590 +++++++++++-------- frontend/renderer/map_renderer.py | 285 +++++++-- frontend/renderer/settings_renderer.py | 429 +++++++++----- frontend/renderer/stats_renderer.py | 777 +++++++++++++----------- frontend/renderer/ui_renderer.py | 672 ++++++++++++++++----- tools/debug_diplomacy.py | 83 +++ tools/optimize_balance.py | 738 +++++++++++++++++++++++ tools/test_religion_diplomacy.py | 238 ++++++++ 23 files changed, 5365 insertions(+), 1580 deletions(-) create mode 100644 backend/domain/diplomacy.py create mode 100644 backend/domain/religion.py create mode 100644 tools/debug_diplomacy.py create mode 100644 tools/optimize_balance.py create mode 100644 tools/test_religion_diplomacy.py diff --git a/backend/api/schemas.py b/backend/api/schemas.py index 8d32fbb..63580f3 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -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 ============== diff --git a/backend/config.py b/backend/config.py index 424ebaf..84d932d 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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 - diff --git a/backend/core/ai.py b/backend/core/ai.py index e714b48..100b916 100644 --- a/backend/core/ai.py +++ b/backend/core/ai.py @@ -2,15 +2,16 @@ Major rework to create diverse, personality-driven economy: - Each agent has unique personality traits affecting all decisions -- Emergent professions: Hunters, Gatherers, Traders, Generalists +- Emergent professions: Hunters, Gatherers, Traders, Generalists, Oil Workers, Priests - Class inequality through varied strategies and skills - Traders focus on arbitrage (buy low, sell high) - Personality affects: risk tolerance, hoarding, market participation -Key insight: Different personalities lead to different strategies. -Traders don't gather - they profit from others' labor. -Hunters take risks for bigger rewards. -Gatherers play it safe. +NEW: Religion and diplomacy integration: +- Agents with high faith perform religious actions (pray, preach) +- Agents consider faction relations in trading decisions +- Diplomatic agents negotiate, declare war, or make peace +- Oil workers focus on drilling and refining """ import random @@ -21,6 +22,10 @@ from backend.domain.agent import Agent from backend.domain.action import ActionType, ACTION_CONFIG from backend.domain.resources import ResourceType from backend.domain.personality import get_trade_price_modifier +from backend.domain.religion import get_religion_action_bonus +from backend.domain.diplomacy import ( + FactionType, get_faction_relations, DiplomaticStatus +) if TYPE_CHECKING: from backend.core.market import OrderBook @@ -49,6 +54,9 @@ class AIDecision: # For price adjustments adjust_order_id: Optional[str] = None new_price: Optional[int] = None + # NEW: For diplomatic/religious actions + target_agent_id: Optional[str] = None + target_faction: Optional[FactionType] = None def to_dict(self) -> dict: return { @@ -69,6 +77,8 @@ class AIDecision: ], "adjust_order_id": self.adjust_order_id, "new_price": self.new_price, + "target_agent_id": self.target_agent_id, + "target_faction": self.target_faction.value if self.target_faction else None, } @@ -80,9 +90,11 @@ RESOURCE_ACTIONS: dict[ResourceType, ActionType] = { ResourceType.WOOD: ActionType.CHOP_WOOD, ResourceType.HIDE: ActionType.HUNT, ResourceType.CLOTHES: ActionType.WEAVE, + ResourceType.OIL: ActionType.DRILL_OIL, + ResourceType.FUEL: ActionType.REFINE, } -# Energy cost to gather each resource (used for efficiency calculations) + def get_energy_cost(resource_type: ResourceType) -> int: """Get the energy cost to produce one unit of a resource.""" action = RESOURCE_ACTIONS.get(resource_type) @@ -110,35 +122,33 @@ def _get_economy_config(): return getattr(config, 'economy', None) +def _get_religion_config(): + """Get religion configuration values.""" + from backend.config import get_config + config = get_config() + return getattr(config, 'religion', None) + + +def _get_diplomacy_config(): + """Get diplomacy configuration values.""" + from backend.config import get_config + config = get_config() + return getattr(config, 'diplomacy', None) + + class AgentAI: """AI decision maker with personality-driven economy behavior. - Core philosophy: Each agent has a unique strategy based on personality. - - Personality effects: - 1. wealth_desire: How aggressively to accumulate money - 2. hoarding_rate: How much to keep vs. sell on market - 3. risk_tolerance: Hunt (risky, high reward) vs. gather (safe) - 4. market_affinity: How often to engage with market - 5. trade_preference: High = trader profession (arbitrage focus) - 6. price_sensitivity: How picky about deals - - Emergent professions: - - Traders: High trade_preference + market_affinity = buy low, sell high - - Hunters: High hunt_preference + risk_tolerance = meat production - - Gatherers: High gather_preference, low risk = safe resource collection - - Generalists: Balanced approach to all activities + Now includes religion and diplomacy considerations. """ # Thresholds for stat management - LOW_THRESHOLD = 0.45 # 45% - proactive action trigger - COMFORT_THRESHOLD = 0.60 # 60% - aim for comfort + LOW_THRESHOLD = 0.55 # Increased from 0.45 to be more proactive about survival + COMFORT_THRESHOLD = 0.70 # Increased from 0.60 + REST_ENERGY_THRESHOLD = 18 + WORK_ENERGY_MINIMUM = 20 - # Energy thresholds - REST_ENERGY_THRESHOLD = 18 # Rest when below this if no urgent needs - WORK_ENERGY_MINIMUM = 20 # Prefer to have this much for work - - # Resource stockpile targets (modified by personality.hoarding_rate) + # Resource stockpile targets BASE_WATER_STOCK = 2 BASE_FOOD_STOCK = 3 BASE_WOOD_STOCK = 2 @@ -146,21 +156,28 @@ class AgentAI: # Heat thresholds HEAT_PROACTIVE_THRESHOLD = 0.50 - # Base economy settings (modified by personality) - ENERGY_TO_MONEY_RATIO = 1.5 # 1 energy = ~1.5 coins in perceived value - MAX_PRICE_MARKUP = 2.0 # Maximum markup over base price - MIN_PRICE_DISCOUNT = 0.5 # Minimum discount (50% of base price) + # Faith thresholds + LOW_FAITH_THRESHOLD = 0.30 + HIGH_FAITH_THRESHOLD = 0.70 - def __init__(self, agent: Agent, market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0): + # Economy settings + ENERGY_TO_MONEY_RATIO = 1.5 + MAX_PRICE_MARKUP = 2.0 + MIN_PRICE_DISCOUNT = 0.5 + + def __init__(self, agent: Agent, market: "OrderBook", step_in_day: int = 1, + day_steps: int = 10, current_turn: int = 0, world = None): self.agent = agent self.market = market self.step_in_day = step_in_day self.day_steps = day_steps self.current_turn = current_turn + self.world = world # NEW: Reference to world for nearby agents - # Personality shortcuts - self.p = agent.personality # Convenience shortcut + self.p = agent.personality self.skills = agent.skills + self.religion = agent.religion + self.diplomacy = agent.diplomacy # Load thresholds from config config = _get_ai_config() @@ -168,161 +185,387 @@ class AgentAI: self.LOW_ENERGY_THRESHOLD = config.low_energy_threshold # Personality-adjusted values - # Wealth desire from personality (0.1 to 0.9) self.WEALTH_DESIRE = self.p.wealth_desire - # Buy efficiency threshold adjusted by price sensitivity - # High sensitivity = only buy very good deals economy = _get_economy_config() base_threshold = getattr(economy, 'buy_efficiency_threshold', 0.7) if economy else 0.7 self.BUY_EFFICIENCY_THRESHOLD = base_threshold / self.p.price_sensitivity - # Wealth target scaled by wealth desire base_target = getattr(economy, 'min_wealth_target', 50) if economy else 50 self.MIN_WEALTH_TARGET = int(base_target * (0.5 + self.p.wealth_desire)) - # Resource stockpile targets modified by hoarding rate - # High hoarders keep more in reserve - hoarding_mult = 0.5 + self.p.hoarding_rate # 0.6 to 1.4 + hoarding_mult = 0.5 + self.p.hoarding_rate self.MIN_WATER_STOCK = max(1, int(self.BASE_WATER_STOCK * hoarding_mult)) self.MIN_FOOD_STOCK = max(2, int(self.BASE_FOOD_STOCK * hoarding_mult)) self.MIN_WOOD_STOCK = max(1, int(self.BASE_WOOD_STOCK * hoarding_mult)) - # Trader mode: agents with high trade preference become market-focused self.is_trader = self.p.trade_preference > 1.3 and self.p.market_affinity > 0.5 + # NEW: Check for special roles + self.is_religious = self.religion.is_religious and self.agent.stats.faith > 40 + self.is_zealot = self.religion.is_zealot + self.is_diplomat = self.diplomacy.diplomacy_skill > 0.6 + @property def is_evening(self) -> bool: - """Check if it's getting close to night (last 2 day steps).""" return self.step_in_day >= self.day_steps - 1 @property def is_late_day(self) -> bool: - """Check if it's past midday (preparation time).""" return self.step_in_day >= self.day_steps // 2 def _get_resource_fair_value(self, resource_type: ResourceType) -> int: - """Calculate the 'fair value' of a resource based on energy cost to produce. - - This is the theoretical minimum price an agent should sell for, - and the maximum they should pay before just gathering themselves. - """ + """Calculate the 'fair value' of a resource.""" energy_cost = get_energy_cost(resource_type) - return max(1, int(energy_cost * self.ENERGY_TO_MONEY_RATIO)) + base_value = max(1, int(energy_cost * self.ENERGY_TO_MONEY_RATIO)) + + # Oil and fuel have special pricing + economy = _get_economy_config() + if economy: + if resource_type == ResourceType.OIL: + return economy.oil_base_price + elif resource_type == ResourceType.FUEL: + return economy.fuel_base_price + + return base_value def _is_good_buy(self, resource_type: ResourceType, price: int) -> bool: - """Check if a market price is a good deal (cheaper than gathering).""" fair_value = self._get_resource_fair_value(resource_type) return price <= fair_value * self.BUY_EFFICIENCY_THRESHOLD def _is_wealthy(self) -> bool: - """Check if agent has comfortable wealth.""" return self.agent.money >= self.MIN_WEALTH_TARGET def decide(self) -> AIDecision: - """Make a decision based on survival, personality, and economic goals. - - Decision flow varies by personality: - - Traders prioritize market operations (arbitrage) - - Hunters prefer hunting when possible - - Gatherers stick to safe resource collection - - All agents prioritize survival when needed - """ - # Priority 1: Critical survival needs (immediate danger) + """Make a decision based on survival, personality, religion, and diplomacy.""" + # Priority 1: Critical survival needs decision = self._check_critical_needs() if decision: return decision - # Priority 2: Proactive survival (prevent problems before they happen) + # Priority 2: Proactive survival decision = self._check_proactive_needs() if decision: return decision - # Priority 3: Trader-specific behavior (high trade_preference agents) - # Traders focus on market operations when survival is secured + # Priority 3: Basic stockpiling - ensure we have food and water before other activities + decision = self._check_basic_stockpile() + if decision: + return decision + + # Priority 4: Religious actions (for religious agents) - only if well-fed + if self.is_religious: + decision = self._check_religious_actions() + if decision: + return decision + + # Priority 5: Diplomatic actions (all non-neutral faction members) + # War can be declared by any aggressive agent, diplomacy by skilled ones + if self.diplomacy.faction != FactionType.NEUTRAL: + decision = self._check_diplomatic_actions() + if decision: + return decision + + # Priority 6: Trader behavior if self.is_trader: decision = self._do_trader_behavior() if decision: return decision - # Priority 4: Price adjustment - respond to market conditions + # Priority 7: Oil industry (if have oil skills or near oil field) + decision = self._check_oil_industry() + if decision: + return decision + + # Priority 8: Price adjustment decision = self._check_price_adjustments() if decision: return decision - # Priority 5: Smart shopping - buy good deals on the market! - # Frequency affected by market_affinity + # Priority 9: Smart shopping if random.random() < self.p.market_affinity: decision = self._check_market_opportunities() if decision: return decision - # Priority 6: Craft clothes if we have hide + # Priority 10: Craft clothes decision = self._check_clothes_crafting() if decision: return decision - # Priority 7: Energy management + # Priority 11: Energy management decision = self._check_energy() if decision: return decision - # Priority 8: Economic activities (sell excess, build wealth) - # Frequency affected by hoarding_rate (low hoarding = sell more) + # Priority 12: Economic activities if random.random() > self.p.hoarding_rate * 0.5: decision = self._check_economic() if decision: return decision - # Priority 9: Routine survival work (gather resources we need) + # Priority 13: Routine survival work return self._do_survival_work() - def _do_trader_behavior(self) -> Optional[AIDecision]: - """Trader-specific behavior: focus on arbitrage and market operations. + def _check_basic_stockpile(self) -> Optional[AIDecision]: + """Ensure basic food and water supplies before other activities.""" + water_count = self.agent.get_resource_count(ResourceType.WATER) + meat_count = self.agent.get_resource_count(ResourceType.MEAT) + berry_count = self.agent.get_resource_count(ResourceType.BERRIES) + food_count = meat_count + berry_count - Traders don't gather much - they profit from buying low and selling high. - Core trader strategy: - 1. Look for arbitrage opportunities (price differences) - 2. Buy underpriced goods - 3. Sell at markup - 4. Build wealth through trading margins - """ - # Traders need money to operate + # Minimum stockpile requirements (more aggressive than hoarding-adjusted values) + MIN_WATER = 2 + MIN_FOOD = 3 + + # Check if we're dangerously low on resources + needs_water = water_count < MIN_WATER + needs_food = food_count < MIN_FOOD + + if not needs_water and not needs_food: + return None + + # Prioritize water since thirst is more urgent + if needs_water: + water_config = ACTION_CONFIG[ActionType.GET_WATER] + if self.agent.stats.can_work(abs(water_config.energy_cost)): + return AIDecision( + action=ActionType.GET_WATER, + target_resource=ResourceType.WATER, + reason=f"Stockpiling water ({water_count} < {MIN_WATER})", + ) + + # Then food + if needs_food: + # Try to gather berries first (cheaper energy cost) + gather_config = ACTION_CONFIG[ActionType.GATHER] + if self.agent.stats.can_work(abs(gather_config.energy_cost)): + return AIDecision( + action=ActionType.GATHER, + target_resource=ResourceType.BERRIES, + reason=f"Stockpiling food ({food_count} < {MIN_FOOD})", + ) + + # If can afford hunting and have energy + hunt_config = ACTION_CONFIG[ActionType.HUNT] + if self.agent.stats.can_work(abs(hunt_config.energy_cost)): + return AIDecision( + action=ActionType.HUNT, + target_resource=ResourceType.MEAT, + reason=f"Stockpiling food by hunting ({food_count} < {MIN_FOOD})", + ) + + return None + + def _check_religious_actions(self) -> Optional[AIDecision]: + """Check if agent should perform religious actions.""" + stats = self.agent.stats + + # Low faith - should pray + if stats.faith < stats.MAX_FAITH * self.LOW_FAITH_THRESHOLD: + pray_config = ACTION_CONFIG[ActionType.PRAY] + if stats.can_work(abs(pray_config.energy_cost)): + return AIDecision( + action=ActionType.PRAY, + reason=f"Low faith ({stats.faith}): praying at temple", + ) + + # Zealots want to preach and convert (but not too often to preserve energy) + if self.is_zealot: + if random.random() < 0.15: # Reduced from 30% to 15% to conserve energy + preach_config = ACTION_CONFIG[ActionType.PREACH] + if stats.can_work(abs(preach_config.energy_cost)): + return AIDecision( + action=ActionType.PREACH, + reason=f"Zealot spreading the word of {self.religion.religion.value}", + ) + + # Religious agents pray occasionally to maintain faith + if stats.faith < stats.MAX_FAITH * self.HIGH_FAITH_THRESHOLD: + if random.random() < 0.15: # 15% chance + pray_config = ACTION_CONFIG[ActionType.PRAY] + if stats.can_work(abs(pray_config.energy_cost)): + return AIDecision( + action=ActionType.PRAY, + reason="Maintaining faith through prayer", + ) + + return None + + def _check_diplomatic_actions(self) -> Optional[AIDecision]: + """Check if agent should perform diplomatic actions.""" + faction_relations = get_faction_relations() + my_faction = self.diplomacy.faction + + if my_faction == FactionType.NEUTRAL: + return None + + # Check each other faction + for other_faction in FactionType: + if other_faction == my_faction or other_faction == FactionType.NEUTRAL: + continue + + status = faction_relations.get_status(my_faction, other_faction) + + # At war and exhausted - try to make peace + if status == DiplomaticStatus.WAR: + exhaustion = faction_relations.war_exhaustion.get(my_faction, 0) + if exhaustion > 30 and random.random() < 0.2: + peace_config = ACTION_CONFIG[ActionType.MAKE_PEACE] + if self.agent.stats.can_work(abs(peace_config.energy_cost)): + return AIDecision( + action=ActionType.MAKE_PEACE, + target_faction=other_faction, + reason=f"War exhaustion ({exhaustion}): seeking peace with {other_faction.value}", + ) + + # Very hostile and aggressive - might declare war (but less frequently) + elif status == DiplomaticStatus.HOSTILE: + # War probability scales with aggression (reduced to promote stability) + # Base 2% chance + up to 8% from aggression + war_prob = 0.08 + (self.diplomacy.aggression * 0.08) + if self.diplomacy.aggression > 0.25 and random.random() < war_prob: + war_cfg = ACTION_CONFIG[ActionType.DECLARE_WAR] + if self.agent.stats.can_work(abs(war_cfg.energy_cost)): + return AIDecision( + action=ActionType.DECLARE_WAR, + target_faction=other_faction, + reason=f"Tensions: declaring war on {other_faction.value}", + ) + + # Cold relations - try to improve + elif status in (DiplomaticStatus.COLD, DiplomaticStatus.NEUTRAL): + if self.diplomacy.diplomacy_skill > 0.6 and random.random() < 0.1: + neg_cfg = ACTION_CONFIG[ActionType.NEGOTIATE] + if self.agent.stats.can_work(abs(neg_cfg.energy_cost)): + return AIDecision( + action=ActionType.NEGOTIATE, + target_faction=other_faction, + reason=f"Improving relations: {other_faction.value}", + ) + + return None + + def _check_oil_industry(self) -> Optional[AIDecision]: + """Check if agent should work in oil industry.""" + stats = self.agent.stats + + # Check if we have oil to refine + if self.agent.has_resource(ResourceType.OIL, 2): + refine_config = ACTION_CONFIG[ActionType.REFINE] + if stats.can_work(abs(refine_config.energy_cost)): + return AIDecision( + action=ActionType.REFINE, + target_resource=ResourceType.FUEL, + reason="Refining oil into fuel", + ) + + # Check if we should burn fuel for heat/energy + if self.agent.has_resource(ResourceType.FUEL): + if stats.heat < stats.MAX_HEAT * 0.5 or stats.energy < 20: + burn_config = ACTION_CONFIG[ActionType.BURN_FUEL] + if stats.can_work(abs(burn_config.energy_cost)): + return AIDecision( + action=ActionType.BURN_FUEL, + target_resource=ResourceType.FUEL, + reason="Burning fuel for energy and heat", + ) + + # Consider drilling for oil (specialized or random chance) + oil_count = self.agent.get_resource_count(ResourceType.OIL) + + # Oil is valuable - check if worth drilling + should_drill = False + + # Mountaineers have oil bonus + if self.diplomacy.faction == FactionType.MOUNTAINEER: + should_drill = oil_count < 3 and random.random() < 0.3 + # Already an oil worker + elif self.agent.actions_performed.get("drill_oil", 0) > 5: + should_drill = oil_count < 5 and random.random() < 0.4 + # Random chance for anyone + elif random.random() < 0.05: + should_drill = True + + if should_drill and self.agent.inventory_space() >= 2: + drill_config = ACTION_CONFIG[ActionType.DRILL_OIL] + if stats.can_work(abs(drill_config.energy_cost)): + return AIDecision( + action=ActionType.DRILL_OIL, + target_resource=ResourceType.OIL, + reason="Drilling for oil", + ) + + return None + + def _check_critical_needs(self) -> Optional[AIDecision]: + """Check if any vital stat is critical.""" + stats = self.agent.stats + + if stats.thirst < stats.MAX_THIRST * self.CRITICAL_THRESHOLD: + return self._address_thirst(critical=True) + + if stats.hunger < stats.MAX_HUNGER * self.CRITICAL_THRESHOLD: + return self._address_hunger(critical=True) + + if stats.heat < stats.MAX_HEAT * self.CRITICAL_THRESHOLD: + return self._address_heat(critical=True) + + return None + + def _check_proactive_needs(self) -> Optional[AIDecision]: + """Proactively address needs before critical.""" + stats = self.agent.stats + + if stats.thirst < stats.MAX_THIRST * self.LOW_THRESHOLD: + return self._address_thirst(critical=False) + + if stats.hunger < stats.MAX_HUNGER * self.LOW_THRESHOLD: + return self._address_hunger(critical=False) + + if stats.heat < stats.MAX_HEAT * self.HEAT_PROACTIVE_THRESHOLD: + decision = self._address_heat(critical=False) + if decision: + return decision + + return None + + def _do_trader_behavior(self) -> Optional[AIDecision]: + """Trader-specific behavior: arbitrage and market operations.""" if self.agent.money < 20: - # Low on capital - need to do some work or sell inventory decision = self._try_to_sell(urgent=True) if decision: return decision - # If nothing to sell, do some quick gathering - return None # Fall through to normal behavior + return None # Look for arbitrage opportunities - # Find resources being sold below fair value best_deal = None best_margin = 0 - for resource_type in [ResourceType.MEAT, ResourceType.BERRIES, ResourceType.WATER, ResourceType.WOOD]: + # Include oil and fuel in trading + trade_resources = [ + ResourceType.MEAT, ResourceType.BERRIES, ResourceType.WATER, + ResourceType.WOOD, ResourceType.OIL, ResourceType.FUEL + ] + + for resource_type in trade_resources: order = self.market.get_cheapest_order(resource_type) if not order or order.seller_id == self.agent.id: continue fair_value = self._get_resource_fair_value(resource_type) - - # Check if we can profit by buying and reselling buy_price = order.price_per_unit - # Apply trading skill to get better prices sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False) - potential_sell_price = int(fair_value * sell_modifier * 1.1) # 10% markup target + potential_sell_price = int(fair_value * sell_modifier * 1.1) margin = potential_sell_price - buy_price if margin > best_margin and self.agent.money >= buy_price: best_margin = margin best_deal = (resource_type, order, buy_price, potential_sell_price) - # Execute arbitrage if profitable - if best_deal and best_margin >= 2: # At least 2 coins profit + if best_deal and best_margin >= 2: resource_type, order, buy_price, sell_price = best_deal - safe_price = max(1, buy_price) # Prevent division by zero + safe_price = max(1, buy_price) quantity = min(3, self.agent.inventory_space(), order.quantity, self.agent.money // safe_price) @@ -333,15 +576,13 @@ class AgentAI: order_id=order.id, quantity=quantity, price=buy_price, - reason=f"Trader: buying {resource_type.value} @ {buy_price}c (resell @ {sell_price}c)", + reason=f"Trader: buying {resource_type.value} @ {buy_price}c", ) - # If holding inventory, try to sell at markup decision = self._try_trader_sell() if decision: return decision - # Adjust prices on existing orders decision = self._check_price_adjustments() if decision: return decision @@ -349,94 +590,42 @@ class AgentAI: return None def _try_trader_sell(self) -> Optional[AIDecision]: - """Trader sells inventory at markup prices.""" + """Trader sells inventory at markup.""" for resource in self.agent.inventory: if resource.type == ResourceType.CLOTHES: continue - # Traders sell everything except minimal survival reserves if resource.quantity <= 1: continue fair_value = self._get_resource_fair_value(resource.type) - - # Apply trading skill for better sell prices sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False) - # Check market conditions signal = self.market.get_market_signal(resource.type) - if signal == "sell": # Scarcity - high markup + if signal == "sell": price = int(fair_value * sell_modifier * 1.4) else: price = int(fair_value * sell_modifier * 1.15) - # Don't undercut ourselves if we have active orders my_orders = [o for o in self.market.get_orders_by_seller(self.agent.id) if o.resource_type == resource.type] if my_orders: - continue # Wait for existing order to sell + continue - quantity = resource.quantity - 1 # Keep 1 for emergencies + quantity = resource.quantity - 1 return AIDecision( action=ActionType.TRADE, target_resource=resource.type, quantity=quantity, price=price, - reason=f"Trader: selling {resource.type.value} @ {price}c (markup)", + reason=f"Trader: selling {resource.type.value} @ {price}c", ) return None - def _check_critical_needs(self) -> Optional[AIDecision]: - """Check if any vital stat is critical and act accordingly.""" - stats = self.agent.stats - - # Check thirst first (depletes fastest and kills quickly) - if stats.thirst < stats.MAX_THIRST * self.CRITICAL_THRESHOLD: - return self._address_thirst(critical=True) - - # Check hunger - if stats.hunger < stats.MAX_HUNGER * self.CRITICAL_THRESHOLD: - return self._address_hunger(critical=True) - - # Check heat - critical level - if stats.heat < stats.MAX_HEAT * self.CRITICAL_THRESHOLD: - return self._address_heat(critical=True) - - return None - - def _check_proactive_needs(self) -> Optional[AIDecision]: - """Proactively address needs before they become critical. - - IMPORTANT CHANGE: Now considers buying as a first option, not last! - """ - stats = self.agent.stats - - # Proactive thirst management - if stats.thirst < stats.MAX_THIRST * self.LOW_THRESHOLD: - return self._address_thirst(critical=False) - - # Proactive hunger management - if stats.hunger < stats.MAX_HUNGER * self.LOW_THRESHOLD: - return self._address_hunger(critical=False) - - # Proactive heat management - if stats.heat < stats.MAX_HEAT * self.HEAT_PROACTIVE_THRESHOLD: - decision = self._address_heat(critical=False) - if decision: - return decision - - return None - def _check_price_adjustments(self) -> Optional[AIDecision]: - """Check if we should adjust prices on our market orders. - - Smart pricing strategy: - - If order is stale (not selling), lower price - - If demand is high (scarcity), raise price - - Respond to market signals - """ + """Check if we should adjust prices on orders.""" my_orders = self.market.get_orders_by_seller(self.agent.id) if not my_orders: return None @@ -447,11 +636,9 @@ class AgentAI: current_price = order.price_per_unit fair_value = self._get_resource_fair_value(resource_type) - # If demand is high and we've waited, consider raising price if signal == "sell" and order.can_raise_price(self.current_turn, min_turns=3): - # Scarcity - raise price (but not too high) new_price = min( - int(current_price * 1.25), # 25% increase + int(current_price * 1.25), int(fair_value * self.MAX_PRICE_MARKUP) ) if new_price > current_price: @@ -463,18 +650,14 @@ class AgentAI: reason=f"Scarcity: raising {resource_type.value} price to {new_price}c", ) - # If order is getting stale (sitting too long), lower price if order.turns_without_sale >= 5: - # Calculate competitive price lowest_order = self.market.get_cheapest_order(resource_type) if lowest_order and lowest_order.id != order.id: - # Price just below the cheapest new_price = max( lowest_order.price_per_unit - 1, int(fair_value * self.MIN_PRICE_DISCOUNT) ) else: - # We're the only seller - slight discount to attract buyers new_price = max( int(current_price * 0.85), int(fair_value * self.MIN_PRICE_DISCOUNT) @@ -486,47 +669,33 @@ class AgentAI: target_resource=resource_type, adjust_order_id=order.id, new_price=new_price, - reason=f"Stale order: lowering {resource_type.value} price to {new_price}c", + reason=f"Stale: lowering {resource_type.value} to {new_price}c", ) return None def _check_market_opportunities(self) -> Optional[AIDecision]: - """Look for good buying opportunities on the market. - - KEY INSIGHT: If market price < energy cost to gather, ALWAYS BUY! - This is the core of smart trading behavior. - - Buying is smart because: - - Trade costs only 1 energy - - Gathering costs 4-8 energy - - If price is low, we're getting resources for less than production cost - """ - # Don't shop if we're low on money and not wealthy + """Look for good buying opportunities.""" if self.agent.money < 10: return None - # Resources we might want to buy shopping_list = [] - # Check each resource type for good deals - for resource_type in [ResourceType.WATER, ResourceType.BERRIES, ResourceType.MEAT, ResourceType.WOOD]: + for resource_type in [ResourceType.WATER, ResourceType.BERRIES, ResourceType.MEAT, + ResourceType.WOOD, ResourceType.OIL, ResourceType.FUEL]: order = self.market.get_cheapest_order(resource_type) if not order or order.seller_id == self.agent.id: continue - # Skip invalid orders (price <= 0) if order.price_per_unit <= 0: continue if self.agent.money < order.price_per_unit: continue - # Calculate if this is a good deal fair_value = self._get_resource_fair_value(resource_type) is_good_deal = self._is_good_buy(resource_type, order.price_per_unit) - # Calculate our current need for this resource current_stock = self.agent.get_resource_count(resource_type) need_score = 0 @@ -538,35 +707,32 @@ class AgentAI: need_score = max(0, self.MIN_FOOD_STOCK - food_stock) * 2 elif resource_type == ResourceType.WOOD: need_score = max(0, self.MIN_WOOD_STOCK - current_stock) * 1 + elif resource_type in [ResourceType.OIL, ResourceType.FUEL]: + # Oil and fuel are valuable trading commodities + need_score = 1 if is_good_deal else 0 - # Score this opportunity if is_good_deal: - # Good deal - definitely consider buying - price = max(1, order.price_per_unit) # Prevent division by zero - efficiency_score = fair_value / price # How much we're saving + price = max(1, order.price_per_unit) + efficiency_score = fair_value / price total_score = need_score + efficiency_score * 2 shopping_list.append((resource_type, order, total_score)) elif need_score > 0 and self._is_wealthy(): - # Not a great deal, but we need it and have money total_score = need_score * 0.5 shopping_list.append((resource_type, order, total_score)) if not shopping_list: return None - # Sort by score and pick the best opportunity shopping_list.sort(key=lambda x: x[2], reverse=True) resource_type, order, score = shopping_list[0] - # Only act if the opportunity is worth it if score < 1: return None - # Calculate how much to buy - price = max(1, order.price_per_unit) # Prevent division by zero + price = max(1, order.price_per_unit) can_afford = self.agent.money // price space = self.agent.inventory_space() - want_quantity = min(2, can_afford, space, order.quantity) # Buy up to 2 at a time + want_quantity = min(2, can_afford, space, order.quantity) if want_quantity <= 0: return None @@ -577,25 +743,21 @@ class AgentAI: order_id=order.id, quantity=want_quantity, price=order.price_per_unit, - reason=f"Good deal: buying {resource_type.value} @ {order.price_per_unit}c (fair value: {self._get_resource_fair_value(resource_type)}c)", + reason=f"Good deal: {resource_type.value} @ {order.price_per_unit}c", ) def _check_clothes_crafting(self) -> Optional[AIDecision]: - """Check if we should craft clothes for heat efficiency.""" - # Only craft if we don't already have clothes and have hide + """Check if we should craft clothes.""" if self.agent.has_clothes(): return None - # Need hide to craft if not self.agent.has_resource(ResourceType.HIDE): return None - # Need energy to craft weave_config = ACTION_CONFIG[ActionType.WEAVE] if not self.agent.stats.can_work(abs(weave_config.energy_cost)): return None - # Only craft if we're not in survival mode stats = self.agent.stats if (stats.thirst < stats.MAX_THIRST * self.LOW_THRESHOLD or stats.hunger < stats.MAX_HUNGER * self.LOW_THRESHOLD): @@ -608,14 +770,9 @@ class AgentAI: ) def _address_thirst(self, critical: bool = False) -> AIDecision: - """Address thirst - water is the primary solution. - - NEW PRIORITY: Try buying FIRST if it's efficient! - Trading uses only 1 energy vs 3 for getting water. - """ + """Address thirst.""" prefix = "Critical" if critical else "Low" - # Step 1: Consume water from inventory (best - immediate, free) if self.agent.has_resource(ResourceType.WATER): return AIDecision( action=ActionType.CONSUME, @@ -623,14 +780,10 @@ class AgentAI: reason=f"{prefix} thirst: consuming water", ) - # Step 2: Check if buying is more efficient than gathering - # Trade = 1 energy, Get water = 3 energy. If price is reasonable, BUY! water_order = self.market.get_cheapest_order(ResourceType.WATER) if water_order and water_order.seller_id != self.agent.id: if self.agent.money >= water_order.price_per_unit: fair_value = self._get_resource_fair_value(ResourceType.WATER) - - # Buy if: good deal OR critical situation OR we're wealthy should_buy = ( self._is_good_buy(ResourceType.WATER, water_order.price_per_unit) or critical or @@ -647,16 +800,14 @@ class AgentAI: reason=f"{prefix} thirst: buying water @ {water_order.price_per_unit}c", ) - # Step 3: Get water ourselves water_config = ACTION_CONFIG[ActionType.GET_WATER] if self.agent.stats.can_work(abs(water_config.energy_cost)): return AIDecision( action=ActionType.GET_WATER, target_resource=ResourceType.WATER, - reason=f"{prefix} thirst: getting water from river", + reason=f"{prefix} thirst: getting water", ) - # Step 4: Emergency - consume berries (gives +3 thirst) if self.agent.has_resource(ResourceType.BERRIES): return AIDecision( action=ActionType.CONSUME, @@ -664,21 +815,15 @@ class AgentAI: reason=f"{prefix} thirst: consuming berries (emergency)", ) - # No energy to get water - rest return AIDecision( action=ActionType.REST, reason=f"{prefix} thirst: too tired, resting", ) def _address_hunger(self, critical: bool = False) -> AIDecision: - """Address hunger - meat is best, berries are backup. - - NEW PRIORITY: Consider buying FIRST if market has good prices! - Trading = 1 energy vs 4-7 for gathering/hunting. - """ + """Address hunger.""" prefix = "Critical" if critical else "Low" - # Step 1: Consume meat from inventory (best for hunger - +40) if self.agent.has_resource(ResourceType.MEAT): return AIDecision( action=ActionType.CONSUME, @@ -686,7 +831,6 @@ class AgentAI: reason=f"{prefix} hunger: consuming meat", ) - # Step 2: Consume berries if we have them (+10 hunger) if self.agent.has_resource(ResourceType.BERRIES): return AIDecision( action=ActionType.CONSUME, @@ -694,13 +838,10 @@ class AgentAI: reason=f"{prefix} hunger: consuming berries", ) - # Step 3: Check if buying food is more efficient than gathering for resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: order = self.market.get_cheapest_order(resource_type) if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit: fair_value = self._get_resource_fair_value(resource_type) - - # Buy if: good deal OR critical OR wealthy should_buy = ( self._is_good_buy(resource_type, order.price_per_unit) or critical or @@ -714,10 +855,9 @@ class AgentAI: order_id=order.id, quantity=1, price=order.price_per_unit, - reason=f"{prefix} hunger: buying {resource_type.value} @ {order.price_per_unit}c", + reason=f"{prefix} hunger: buying {resource_type.value}", ) - # Step 4: Gather berries ourselves (easy, 100% success) gather_config = ACTION_CONFIG[ActionType.GATHER] if self.agent.stats.can_work(abs(gather_config.energy_cost)): return AIDecision( @@ -726,21 +866,25 @@ class AgentAI: reason=f"{prefix} hunger: gathering berries", ) - # No energy - rest return AIDecision( action=ActionType.REST, reason=f"{prefix} hunger: too tired, resting", ) def _address_heat(self, critical: bool = False) -> Optional[AIDecision]: - """Address heat by building fire or getting wood. - - NOW: Always considers buying wood if it's a good deal! - Trade = 1 energy vs 8 energy for chopping. - """ + """Address heat.""" prefix = "Critical" if critical else "Low" - # Step 1: Build fire if we have wood + # Fuel is better than wood for heat + if self.agent.has_resource(ResourceType.FUEL): + burn_config = ACTION_CONFIG[ActionType.BURN_FUEL] + if self.agent.stats.can_work(abs(burn_config.energy_cost)): + return AIDecision( + action=ActionType.BURN_FUEL, + target_resource=ResourceType.FUEL, + reason=f"{prefix} heat: burning fuel", + ) + if self.agent.has_resource(ResourceType.WOOD): fire_config = ACTION_CONFIG[ActionType.BUILD_FIRE] if self.agent.stats.can_work(abs(fire_config.energy_cost)): @@ -750,64 +894,55 @@ class AgentAI: reason=f"{prefix} heat: building fire", ) - # Step 2: Buy wood if available and efficient - cheapest = self.market.get_cheapest_order(ResourceType.WOOD) - if cheapest and cheapest.seller_id != self.agent.id and self.agent.money >= cheapest.price_per_unit: - fair_value = self._get_resource_fair_value(ResourceType.WOOD) - - # Buy if: good deal OR critical OR wealthy - should_buy = ( - self._is_good_buy(ResourceType.WOOD, cheapest.price_per_unit) or - critical or - (self._is_wealthy() and cheapest.price_per_unit <= fair_value * 1.5) - ) - - if should_buy: - return AIDecision( - action=ActionType.TRADE, - target_resource=ResourceType.WOOD, - order_id=cheapest.id, - quantity=1, - price=cheapest.price_per_unit, - reason=f"{prefix} heat: buying wood @ {cheapest.price_per_unit}c", - ) + # Try to buy fuel or wood + for resource_type in [ResourceType.FUEL, ResourceType.WOOD]: + cheapest = self.market.get_cheapest_order(resource_type) + if cheapest and cheapest.seller_id != self.agent.id: + if self.agent.money >= cheapest.price_per_unit: + fair_value = self._get_resource_fair_value(resource_type) + should_buy = ( + self._is_good_buy(resource_type, cheapest.price_per_unit) or + critical or + (self._is_wealthy() and cheapest.price_per_unit <= fair_value * 1.5) + ) + + if should_buy: + return AIDecision( + action=ActionType.TRADE, + target_resource=resource_type, + order_id=cheapest.id, + quantity=1, + price=cheapest.price_per_unit, + reason=f"{prefix} heat: buying {resource_type.value}", + ) - # Step 3: Chop wood ourselves chop_config = ACTION_CONFIG[ActionType.CHOP_WOOD] if self.agent.stats.can_work(abs(chop_config.energy_cost)): return AIDecision( action=ActionType.CHOP_WOOD, target_resource=ResourceType.WOOD, - reason=f"{prefix} heat: chopping wood for fire", + reason=f"{prefix} heat: chopping wood", ) - # If not critical, return None to let other priorities take over if not critical: return None return AIDecision( action=ActionType.REST, - reason=f"{prefix} heat: too tired to get wood, resting", + reason=f"{prefix} heat: too tired, resting", ) def _check_energy(self) -> Optional[AIDecision]: - """Check if energy management is needed. - - Improved logic: Don't rest at 13-14 energy just to rest. - Instead, rest only if we truly can't do essential work. - """ + """Check if energy management is needed.""" stats = self.agent.stats - # Only rest if energy is very low if stats.energy < self.LOW_ENERGY_THRESHOLD: return AIDecision( action=ActionType.REST, reason=f"Energy critically low ({stats.energy}), must rest", ) - # If it's evening and energy is moderate, rest to prepare for night if self.is_evening and stats.energy < self.REST_ENERGY_THRESHOLD: - # Only if we have enough supplies has_supplies = ( self.agent.get_resource_count(ResourceType.WATER) >= 1 and (self.agent.get_resource_count(ResourceType.MEAT) >= 1 or @@ -816,26 +951,17 @@ class AgentAI: if has_supplies: return AIDecision( action=ActionType.REST, - reason=f"Evening: resting to prepare for night", + reason="Evening: resting for night", ) return None def _check_economic(self) -> Optional[AIDecision]: - """Economic activities: selling, wealth building, market participation. - - NEW PHILOSOPHY: Actively participate in the market! - - Sell excess resources to build wealth - - Price based on supply/demand, not just to clear inventory - - Wealth = safety = survival - """ - # Proactive selling - not just when inventory is full - # If we have excess and market is favorable, sell! + """Economic activities.""" decision = self._try_proactive_sell() if decision: return decision - # If inventory is getting full, must sell if self.agent.inventory_space() <= 2: decision = self._try_to_sell(urgent=True) if decision: @@ -844,41 +970,32 @@ class AgentAI: return None def _try_proactive_sell(self) -> Optional[AIDecision]: - """Proactively sell when market conditions are good. - - Affected by personality: - - hoarding_rate: High hoarders keep more, sell less - - wealth_desire: High wealth desire = more aggressive selling - - trade_preference: High traders sell more frequently - """ + """Proactively sell when market conditions are good.""" if self._is_wealthy() and self.agent.inventory_space() > 3: - # Already rich and have space, no rush to sell return None - # Hoarders are reluctant to sell if random.random() < self.p.hoarding_rate * 0.7: return None - # Survival minimums scaled by hoarding rate base_min = { ResourceType.WATER: 2, ResourceType.MEAT: 1, ResourceType.BERRIES: 2, ResourceType.WOOD: 2, ResourceType.HIDE: 0, + ResourceType.OIL: 0, + ResourceType.FUEL: 1, } - # High hoarders keep more hoarding_mult = 0.5 + self.p.hoarding_rate survival_minimums = {k: int(v * hoarding_mult) for k, v in base_min.items()} - # Look for profitable sales best_opportunity = None best_score = 0 for resource in self.agent.inventory: if resource.type == ResourceType.CLOTHES: - continue # Don't sell clothes + continue min_keep = survival_minimums.get(resource.type, 1) excess = resource.quantity - min_keep @@ -886,35 +1003,33 @@ class AgentAI: if excess <= 0: continue - # Check market conditions signal = self.market.get_market_signal(resource.type) fair_value = self._get_resource_fair_value(resource.type) - - # Apply trading skill for better sell prices sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False) - # Calculate optimal price - if signal == "sell": # Scarcity - we can charge more + if signal == "sell": price = int(fair_value * 1.3 * sell_modifier) score = 3 + excess - elif signal == "hold": # Normal market + elif signal == "hold": price = int(fair_value * sell_modifier) score = 1 + excess * 0.5 - else: # Surplus - price competitively - # Find cheapest competitor + else: cheapest = self.market.get_cheapest_order(resource.type) if cheapest and cheapest.seller_id != self.agent.id: price = max(1, cheapest.price_per_unit - 1) else: price = int(fair_value * 0.8 * sell_modifier) - score = 0.5 # Not a great time to sell + score = 0.5 + + # Oil and fuel have higher sell priority + if resource.type in [ResourceType.OIL, ResourceType.FUEL]: + score *= 1.5 - # Wealth desire increases sell motivation score *= (0.7 + self.p.wealth_desire * 0.6) if score > best_score: best_score = score - best_opportunity = (resource.type, min(excess, 3), price) # Sell up to 3 at a time + best_opportunity = (resource.type, min(excess, 3), price) if best_opportunity and best_score >= 1: resource_type, quantity, price = best_opportunity @@ -929,13 +1044,15 @@ class AgentAI: return None def _try_to_sell(self, urgent: bool = False) -> Optional[AIDecision]: - """Sell excess resources, keeping enough for survival.""" + """Sell excess resources.""" survival_minimums = { ResourceType.WATER: 2 if urgent else 3, ResourceType.MEAT: 1 if urgent else 2, ResourceType.BERRIES: 2 if urgent else 3, ResourceType.WOOD: 1 if urgent else 2, ResourceType.HIDE: 0, + ResourceType.OIL: 0, + ResourceType.FUEL: 0, } for resource in self.agent.inventory: @@ -957,54 +1074,36 @@ class AgentAI: return None def _calculate_sell_price(self, resource_type: ResourceType) -> int: - """Calculate sell price based on fair value and market conditions.""" + """Calculate sell price.""" fair_value = self._get_resource_fair_value(resource_type) - - # Get market suggestion suggested = self.market.get_suggested_price(resource_type, fair_value) - # Check competition cheapest = self.market.get_cheapest_order(resource_type) if cheapest and cheapest.seller_id != self.agent.id: - # Don't price higher than cheapest competitor unless scarcity signal = self.market.get_market_signal(resource_type) if signal != "sell": - # Match or undercut suggested = min(suggested, cheapest.price_per_unit) return max(1, suggested) def _do_survival_work(self) -> AIDecision: - """Perform work based on survival needs AND personality preferences. - - Personality effects: - - hunt_preference: Likelihood of choosing to hunt - - gather_preference: Likelihood of choosing to gather - - risk_tolerance: Affects hunt vs gather choice - - market_affinity: Likelihood of buying vs gathering - """ + """Perform work based on survival needs and personality.""" stats = self.agent.stats - # Count current resources water_count = self.agent.get_resource_count(ResourceType.WATER) meat_count = self.agent.get_resource_count(ResourceType.MEAT) berry_count = self.agent.get_resource_count(ResourceType.BERRIES) wood_count = self.agent.get_resource_count(ResourceType.WOOD) food_count = meat_count + berry_count - # Urgency calculations heat_urgency = 1 - (stats.heat / stats.MAX_HEAT) - # Helper to decide: buy or gather? def get_resource_decision(resource_type: ResourceType, gather_action: ActionType, reason: str) -> AIDecision: - """Decide whether to buy or gather a resource.""" - # Check if buying is efficient (affected by market_affinity) if random.random() < self.p.market_affinity: order = self.market.get_cheapest_order(resource_type) if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit: - # Apply trading skill for better buy prices buy_modifier = get_trade_price_modifier(self.skills.trading, is_buying=True) - effective_price = order.price_per_unit # Skill affects perceived value + effective_price = order.price_per_unit if self._is_good_buy(resource_type, effective_price): return AIDecision( @@ -1013,12 +1112,15 @@ class AgentAI: order_id=order.id, quantity=1, price=order.price_per_unit, - reason=f"{reason} (buying @ {order.price_per_unit}c)", + reason=f"{reason} (buying)", ) - # Gather it ourselves config = ACTION_CONFIG[gather_action] if self.agent.stats.can_work(abs(config.energy_cost)): + # Apply religion bonus + religion_bonus = get_religion_action_bonus( + self.religion.religion, gather_action.value + ) return AIDecision( action=gather_action, target_resource=resource_type, @@ -1026,7 +1128,6 @@ class AgentAI: ) return None - # Priority: Stock up on water if low if water_count < self.MIN_WATER_STOCK: decision = get_resource_decision( ResourceType.WATER, @@ -1036,8 +1137,6 @@ class AgentAI: if decision: return decision - # Priority: Stock up on wood if low (for heat) - # Affected by woodcut_preference if wood_count < self.MIN_WOOD_STOCK and heat_urgency > 0.3: decision = get_resource_decision( ResourceType.WOOD, @@ -1047,24 +1146,16 @@ class AgentAI: if decision: return decision - # Priority: Stock up on food if low if food_count < self.MIN_FOOD_STOCK: hunt_config = ACTION_CONFIG[ActionType.HUNT] - - # Personality-driven choice between hunting and gathering - # risk_tolerance and hunt_preference affect this choice can_hunt = stats.energy >= abs(hunt_config.energy_cost) + 5 - # Calculate hunt probability based on personality - # High risk_tolerance + high hunt_preference = more hunting hunt_score = self.p.hunt_preference * self.p.risk_tolerance gather_score = self.p.gather_preference * (1.5 - self.p.risk_tolerance) - # Normalize to probability total = hunt_score + gather_score hunt_prob = hunt_score / total if total > 0 else 0.3 - # Also prefer hunting if we have no meat if meat_count == 0: hunt_prob = min(0.8, hunt_prob + 0.3) @@ -1074,12 +1165,11 @@ class AgentAI: decision = get_resource_decision( ResourceType.MEAT, ActionType.HUNT, - f"Hunting for food ({food_count} < {self.MIN_FOOD_STOCK})" + f"Hunting ({food_count} < {self.MIN_FOOD_STOCK})" ) if decision: return decision - # Otherwise try berries decision = get_resource_decision( ResourceType.BERRIES, ActionType.GATHER, @@ -1103,49 +1193,43 @@ class AgentAI: if decision: return decision - # Default: varied work based on need AND personality preferences + # Default work needs = [] if water_count < self.MIN_WATER_STOCK + 2: needs.append((ResourceType.WATER, ActionType.GET_WATER, "Getting water", 2.0)) if food_count < self.MIN_FOOD_STOCK + 2: - # Weight by personality preferences needs.append((ResourceType.BERRIES, ActionType.GATHER, "Gathering berries", 2.0 * self.p.gather_preference)) - # Add hunting weighted by personality hunt_config = ACTION_CONFIG[ActionType.HUNT] if stats.energy >= abs(hunt_config.energy_cost) + 3: hunt_weight = 2.0 * self.p.hunt_preference * self.p.risk_tolerance - needs.append((ResourceType.MEAT, ActionType.HUNT, "Hunting for meat", hunt_weight)) + needs.append((ResourceType.MEAT, ActionType.HUNT, "Hunting", hunt_weight)) if wood_count < self.MIN_WOOD_STOCK + 2: needs.append((ResourceType.WOOD, ActionType.CHOP_WOOD, "Chopping wood", 1.0 * self.p.woodcut_preference)) if not needs: - # We have good reserves, maybe sell excess or rest if self.agent.inventory_space() <= 4: decision = self._try_proactive_sell() if decision: return decision - # Default activity based on personality - # High hunt_preference = hunt, else gather if self.p.hunt_preference > self.p.gather_preference and stats.energy >= 10: return AIDecision( action=ActionType.HUNT, target_resource=ResourceType.MEAT, - reason="Default: hunting (personality)", + reason="Default: hunting", ) return AIDecision( action=ActionType.GATHER, target_resource=ResourceType.BERRIES, - reason="Default: gathering (personality)", + reason="Default: gathering", ) - # For each need, check if we can buy cheaply (market_affinity affects this) if random.random() < self.p.market_affinity: for resource_type, action, reason, weight in needs: order = self.market.get_cheapest_order(resource_type) @@ -1157,10 +1241,9 @@ class AgentAI: order_id=order.id, quantity=1, price=order.price_per_unit, - reason=f"{reason} (buying cheap!)", + reason=f"{reason} (buying cheap)", ) - # Weighted random selection for gathering total_weight = sum(weight for _, _, _, weight in needs) r = random.random() * total_weight cumulative = 0 @@ -1173,7 +1256,6 @@ class AgentAI: reason=reason, ) - # Fallback resource, action, reason, _ = needs[0] return AIDecision( action=action, @@ -1182,7 +1264,8 @@ class AgentAI: ) -def get_ai_decision(agent: Agent, market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0) -> AIDecision: +def get_ai_decision(agent: Agent, market: "OrderBook", step_in_day: int = 1, + day_steps: int = 10, current_turn: int = 0, world = None) -> AIDecision: """Convenience function to get an AI decision for an agent.""" - ai = AgentAI(agent, market, step_in_day, day_steps, current_turn) + ai = AgentAI(agent, market, step_in_day, day_steps, current_turn, world) return ai.decide() diff --git a/backend/core/engine.py b/backend/core/engine.py index 8aeefa6..15434fe 100644 --- a/backend/core/engine.py +++ b/backend/core/engine.py @@ -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() diff --git a/backend/core/world.py b/backend/core/world.py index e8211a2..7fd4663 100644 --- a/backend/core/world.py +++ b/backend/core/world.py @@ -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) diff --git a/backend/domain/__init__.py b/backend/domain/__init__.py index a72ab3d..ae9117d 100644 --- a/backend/domain/__init__.py +++ b/backend/domain/__init__.py @@ -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", ] - diff --git a/backend/domain/action.py b/backend/domain/action.py index 2b46ecb..e05195e 100644 --- a/backend/domain/action.py +++ b/backend/domain/action.py @@ -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, } diff --git a/backend/domain/agent.py b/backend/domain/agent.py index 3b54ba6..ecb3b3b 100644 --- a/backend/domain/agent.py +++ b/backend/domain/agent.py @@ -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(), } diff --git a/backend/domain/diplomacy.py b/backend/domain/diplomacy.py new file mode 100644 index 0000000..53accd6 --- /dev/null +++ b/backend/domain/diplomacy.py @@ -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 + diff --git a/backend/domain/religion.py b/backend/domain/religion.py new file mode 100644 index 0000000..ba171e6 --- /dev/null +++ b/backend/domain/religion.py @@ -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 + diff --git a/backend/domain/resources.py b/backend/domain/resources.py index 03af224..3171b76 100644 --- a/backend/domain/resources.py +++ b/backend/domain/resources.py @@ -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, } diff --git a/config.json b/config.json index 7cbff9c..b8e081a 100644 --- a/config.json +++ b/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 -} \ No newline at end of file + "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 +} diff --git a/frontend/client.py b/frontend/client.py index 2177820..e9099a2 100644 --- a/frontend/client.py +++ b/frontend/client.py @@ -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) - diff --git a/frontend/main.py b/frontend/main.py index 62a1742..e26c030 100644 --- a/frontend/main.py +++ b/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] diff --git a/frontend/renderer/__init__.py b/frontend/renderer/__init__.py index 49cf007..8f335a1 100644 --- a/frontend/renderer/__init__.py +++ b/frontend/renderer/__init__.py @@ -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", +] diff --git a/frontend/renderer/agent_renderer.py b/frontend/renderer/agent_renderer.py index e8d5cfa..4634e56 100644 --- a/frontend/renderer/agent_renderer.py +++ b/frontend/renderer/agent_renderer.py @@ -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)) diff --git a/frontend/renderer/map_renderer.py b/frontend/renderer/map_renderer.py index 6ed2a2d..34c9718 100644 --- a/frontend/renderer/map_renderer.py +++ b/frontend/renderer/map_renderer.py @@ -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) diff --git a/frontend/renderer/settings_renderer.py b/frontend/renderer/settings_renderer.py index 00add4b..8983a3a 100644 --- a/frontend/renderer/settings_renderer.py +++ b/frontend/renderer/settings_renderer.py @@ -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) diff --git a/frontend/renderer/stats_renderer.py b/frontend/renderer/stats_renderer.py index 7a2d5ea..7722102 100644 --- a/frontend/renderer/stats_renderer.py +++ b/frontend/renderer/stats_renderer.py @@ -1,6 +1,7 @@ """Real-time statistics and charts renderer for the Village Simulation. -Uses matplotlib to render charts to pygame surfaces for a seamless visualization experience. +Includes tabs for Economy, Religion, Factions, and Diplomacy. +Uses matplotlib for beautiful data visualization. """ import io @@ -10,7 +11,7 @@ from typing import TYPE_CHECKING, Optional import pygame import matplotlib -matplotlib.use('Agg') # Use non-interactive backend for pygame integration +matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.ticker as mticker from matplotlib.figure import Figure @@ -20,16 +21,15 @@ if TYPE_CHECKING: from frontend.client import SimulationState -# Color scheme - dark cyberpunk inspired class ChartColors: - """Color palette for charts - dark theme with neon accents.""" - BG = '#1a1d26' - PANEL = '#252a38' - GRID = '#2f3545' - TEXT = '#e0e0e8' - TEXT_DIM = '#7a7e8c' + """Color palette for charts - dark cyberpunk theme.""" + BG = '#0f1117' + PANEL = '#1a1d26' + GRID = '#2a2f3d' + TEXT = '#e0e4eb' + TEXT_DIM = '#6b7280' - # Neon accents for data series + # Neon accents CYAN = '#00d4ff' MAGENTA = '#ff0099' LIME = '#39ff14' @@ -39,49 +39,76 @@ class ChartColors: TEAL = '#00ffa3' PINK = '#ff1493' - # Series colors for different resources/categories SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK] + + # Faction colors (matching backend) + FACTIONS = { + "northlands": "#64a0dc", + "riverfolk": "#46a0b4", + "forestkin": "#5aa050", + "mountaineer": "#96785a", + "plainsmen": "#c8b464", + "neutral": "#787878", + } + + # Religion colors + RELIGIONS = { + "solaris": "#ffc850", + "aquarius": "#50aaf0", + "terranus": "#a07846", + "ignis": "#f06432", + "naturis": "#64c864", + "atheist": "#8c8c8c", + } class UIColors: - """Color palette for pygame UI elements.""" - BG = (26, 29, 38) - PANEL_BG = (37, 42, 56) - PANEL_BORDER = (70, 80, 100) - TEXT_PRIMARY = (224, 224, 232) - TEXT_SECONDARY = (122, 126, 140) + """Color palette for pygame UI.""" + BG = (15, 17, 23) + PANEL_BG = (26, 29, 38) + PANEL_BORDER = (55, 65, 85) + TEXT_PRIMARY = (224, 228, 235) + TEXT_SECONDARY = (107, 114, 128) TEXT_HIGHLIGHT = (0, 212, 255) TAB_ACTIVE = (0, 212, 255) - TAB_INACTIVE = (55, 60, 75) - TAB_HOVER = (75, 85, 110) + TAB_INACTIVE = (45, 50, 65) + TAB_HOVER = (65, 75, 95) @dataclass class HistoryData: """Stores historical simulation data for charting.""" - max_history: int = 200 + max_history: int = 300 - # Time series data - turns: deque = field(default_factory=lambda: deque(maxlen=200)) - population: deque = field(default_factory=lambda: deque(maxlen=200)) - deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=200)) + # Time series + turns: deque = field(default_factory=lambda: deque(maxlen=300)) + population: deque = field(default_factory=lambda: deque(maxlen=300)) + deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=300)) - # Money/Wealth data - total_money: deque = field(default_factory=lambda: deque(maxlen=200)) - avg_wealth: deque = field(default_factory=lambda: deque(maxlen=200)) - gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=200)) + # Wealth data + total_money: deque = field(default_factory=lambda: deque(maxlen=300)) + avg_wealth: deque = field(default_factory=lambda: deque(maxlen=300)) + gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=300)) - # Price history per resource - prices: dict = field(default_factory=dict) # resource -> deque of prices + # Price history + prices: dict = field(default_factory=dict) # Trade statistics - trade_volume: deque = field(default_factory=lambda: deque(maxlen=200)) + trade_volume: deque = field(default_factory=lambda: deque(maxlen=300)) - # Profession counts over time - professions: dict = field(default_factory=dict) # profession -> deque of counts + # Faction data + factions: dict = field(default_factory=dict) + + # Religion data + religions: dict = field(default_factory=dict) + avg_faith: deque = field(default_factory=lambda: deque(maxlen=300)) + + # Diplomacy data + active_wars: deque = field(default_factory=lambda: deque(maxlen=300)) + peace_treaties: deque = field(default_factory=lambda: deque(maxlen=300)) def clear(self) -> None: - """Clear all history data.""" + """Clear all history.""" self.turns.clear() self.population.clear() self.deaths_cumulative.clear() @@ -90,56 +117,68 @@ class HistoryData: self.gini_coefficient.clear() self.prices.clear() self.trade_volume.clear() - self.professions.clear() + self.factions.clear() + self.religions.clear() + self.avg_faith.clear() + self.active_wars.clear() + self.peace_treaties.clear() def update(self, state: "SimulationState") -> None: - """Update history with new state data.""" + """Update history with new state.""" turn = state.turn - # Avoid duplicate entries for the same turn if self.turns and self.turns[-1] == turn: return self.turns.append(turn) # Population - living = len([a for a in state.agents if a.get("is_alive", False)]) + living = len(state.get_living_agents()) self.population.append(living) self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0)) - # Wealth data + # Wealth stats = state.statistics self.total_money.append(stats.get("total_money_in_circulation", 0)) self.avg_wealth.append(stats.get("avg_money", 0)) self.gini_coefficient.append(stats.get("gini_coefficient", 0)) - # Price history from market + # Prices for resource, data in state.market_prices.items(): if resource not in self.prices: self.prices[resource] = deque(maxlen=self.max_history) - - # Track lowest price (current market rate) - lowest = data.get("lowest_price") - avg = data.get("avg_sale_price") - # Use lowest price if available, else avg sale price - price = lowest if lowest is not None else avg + price = data.get("lowest_price") or data.get("avg_sale_price") self.prices[resource].append(price) - # Trade volume (from recent trades in market orders) - trades = len(state.market_orders) # Active orders as proxy - self.trade_volume.append(trades) + # Trade volume + self.trade_volume.append(len(state.market_orders)) - # Profession distribution - professions = stats.get("professions", {}) - for prof, count in professions.items(): - if prof not in self.professions: - self.professions[prof] = deque(maxlen=self.max_history) - self.professions[prof].append(count) + # Factions + faction_stats = state.get_faction_stats() + for faction, count in faction_stats.items(): + if faction not in self.factions: + self.factions[faction] = deque(maxlen=self.max_history) + self.factions[faction].append(count) + for faction in self.factions: + if faction not in faction_stats: + self.factions[faction].append(0) - # Pad missing professions with 0 - for prof in self.professions: - if prof not in professions: - self.professions[prof].append(0) + # Religions + religion_stats = state.get_religion_stats() + for religion, count in religion_stats.items(): + if religion not in self.religions: + self.religions[religion] = deque(maxlen=self.max_history) + self.religions[religion].append(count) + for religion in self.religions: + if religion not in religion_stats: + self.religions[religion].append(0) + + # Faith + self.avg_faith.append(state.get_avg_faith()) + + # Diplomacy + self.active_wars.append(len(state.active_wars)) + self.peace_treaties.append(len(state.peace_treaties)) class ChartRenderer: @@ -150,7 +189,7 @@ class ChartRenderer: self.height = height self.dpi = 100 - # Configure matplotlib style + # Configure matplotlib plt.style.use('dark_background') plt.rcParams.update({ 'figure.facecolor': ChartColors.BG, @@ -165,27 +204,27 @@ class ChartRenderer: 'legend.facecolor': ChartColors.PANEL, 'legend.edgecolor': ChartColors.GRID, 'font.size': 9, - 'axes.titlesize': 11, + 'axes.titlesize': 12, 'axes.titleweight': 'bold', }) def _fig_to_surface(self, fig: Figure) -> pygame.Surface: - """Convert a matplotlib figure to a pygame surface.""" + """Convert matplotlib figure to pygame surface.""" buf = io.BytesIO() - fig.savefig(buf, format='png', dpi=self.dpi, - facecolor=ChartColors.BG, edgecolor='none', - bbox_inches='tight', pad_inches=0.1) + fig.savefig( + buf, format='png', dpi=self.dpi, + facecolor=ChartColors.BG, edgecolor='none', + bbox_inches='tight', pad_inches=0.1 + ) buf.seek(0) - surface = pygame.image.load(buf, 'png') buf.close() plt.close(fig) - return surface - def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface: - """Render price history chart for all resources.""" - fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) + def render_price_history(self, history: HistoryData, w: int, h: int) -> pygame.Surface: + """Render price history chart.""" + fig, ax = plt.subplots(figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi) turns = list(history.turns) if history.turns else [0] @@ -193,49 +232,46 @@ class ChartRenderer: for i, (resource, prices) in enumerate(history.prices.items()): if prices and any(p is not None for p in prices): color = ChartColors.SERIES[i % len(ChartColors.SERIES)] - # Filter out None values - valid_prices = [p if p is not None else 0 for p in prices] - # Align with turns - min_len = min(len(turns), len(valid_prices)) - ax.plot(list(turns)[-min_len:], valid_prices[-min_len:], - color=color, linewidth=1.5, label=resource.capitalize(), alpha=0.9) + valid = [p if p is not None else 0 for p in prices] + min_len = min(len(turns), len(valid)) + ax.plot( + list(turns)[-min_len:], valid[-min_len:], + color=color, linewidth=1.5, label=resource.title(), alpha=0.9 + ) has_data = True - ax.set_title('Market Prices', color=ChartColors.CYAN) + ax.set_title('Market Prices Over Time', color=ChartColors.CYAN) ax.set_xlabel('Turn') ax.set_ylabel('Price (coins)') ax.grid(True, alpha=0.2) if has_data: - ax.legend(loc='upper left', fontsize=8, framealpha=0.8) + ax.legend(loc='upper left', fontsize=8, framealpha=0.8, ncol=2) ax.set_ylim(bottom=0) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) - fig.tight_layout() return self._fig_to_surface(fig) - def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface: - """Render population over time chart.""" - fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) + def render_population(self, history: HistoryData, w: int, h: int) -> pygame.Surface: + """Render population chart.""" + fig, ax = plt.subplots(figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi) turns = list(history.turns) if history.turns else [0] - population = list(history.population) if history.population else [0] + pop = list(history.population) if history.population else [0] deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0] - min_len = min(len(turns), len(population)) + min_len = min(len(turns), len(pop)) - # Population line - ax.fill_between(turns[-min_len:], population[-min_len:], - alpha=0.3, color=ChartColors.CYAN) - ax.plot(turns[-min_len:], population[-min_len:], - color=ChartColors.CYAN, linewidth=2, label='Living') + ax.fill_between(turns[-min_len:], pop[-min_len:], alpha=0.3, color=ChartColors.CYAN) + ax.plot(turns[-min_len:], pop[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Living') - # Deaths line if deaths: - ax.plot(turns[-min_len:], deaths[-min_len:], - color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--', - label='Total Deaths', alpha=0.8) + ax.plot( + turns[-min_len:], deaths[-min_len:], + color=ChartColors.MAGENTA, linewidth=1.5, + linestyle='--', label='Deaths', alpha=0.8 + ) ax.set_title('Population Over Time', color=ChartColors.LIME) ax.set_xlabel('Turn') @@ -244,16 +280,14 @@ class ChartRenderer: ax.legend(loc='upper right', fontsize=8) ax.set_ylim(bottom=0) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) - fig.tight_layout() return self._fig_to_surface(fig) - def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: - """Render current wealth distribution as a bar chart.""" - fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) + def render_wealth_distribution(self, state: "SimulationState", w: int, h: int) -> pygame.Surface: + """Render wealth distribution bar chart.""" + fig, ax = plt.subplots(figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi) - # Get agent wealth data - agents = [a for a in state.agents if a.get("is_alive", False)] + agents = state.get_living_agents() if not agents: ax.text(0.5, 0.5, 'No living agents', ha='center', va='center', color=ChartColors.TEXT_DIM, fontsize=12) @@ -261,42 +295,40 @@ class ChartRenderer: fig.tight_layout() return self._fig_to_surface(fig) - # Sort by wealth - agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True) - names = [a.get("name", "?")[:8] for a in agents_sorted] - wealth = [a.get("money", 0) for a in agents_sorted] + # Sort by wealth and show top 20 + sorted_agents = sorted(agents, key=lambda a: a.get("money", 0), reverse=True)[:20] + names = [a.get("name", "?")[:8] for a in sorted_agents] + wealth = [a.get("money", 0) for a in sorted_agents] - # Create gradient colors based on wealth ranking colors = [] - for i in range(len(agents_sorted)): - ratio = i / max(1, len(agents_sorted) - 1) - # Gradient from cyan (rich) to magenta (poor) - r = int(0 + ratio * 255) - g = int(212 - ratio * 212) - b = int(255 - ratio * 102) - colors.append(f'#{r:02x}{g:02x}{b:02x}') + for i, agent in enumerate(sorted_agents): + diplomacy = agent.get("diplomacy", {}) + faction = diplomacy.get("faction", "neutral") + colors.append(ChartColors.FACTIONS.get(faction, "#787878")) - bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85) - ax.set_yticks(range(len(agents_sorted))) + bars = ax.barh(range(len(sorted_agents)), wealth, color=colors, alpha=0.85) + ax.set_yticks(range(len(sorted_agents))) ax.set_yticklabels(names, fontsize=7) - ax.invert_yaxis() # Rich at top + ax.invert_yaxis() - # Add value labels for bar, val in zip(bars, wealth): - ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2, - f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM) + ax.text( + bar.get_width() + 1, bar.get_y() + bar.get_height()/2, + f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM + ) - ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) + ax.set_title('Wealth Distribution (Top 20)', color=ChartColors.ORANGE) ax.set_xlabel('Coins') ax.grid(True, alpha=0.2, axis='x') - fig.tight_layout() return self._fig_to_surface(fig) - def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface: - """Render wealth metrics over time (total money, avg, gini).""" - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(width/self.dpi, height/self.dpi), - dpi=self.dpi, height_ratios=[2, 1]) + def render_wealth_over_time(self, history: HistoryData, w: int, h: int) -> pygame.Surface: + """Render wealth metrics over time.""" + fig, (ax1, ax2) = plt.subplots( + 2, 1, figsize=(w/self.dpi, h/self.dpi), + dpi=self.dpi, height_ratios=[2, 1] + ) turns = list(history.turns) if history.turns else [0] total = list(history.total_money) if history.total_money else [0] @@ -305,160 +337,230 @@ class ChartRenderer: min_len = min(len(turns), len(total), len(avg)) - # Total and average wealth - ax1.plot(turns[-min_len:], total[-min_len:], - color=ChartColors.CYAN, linewidth=2, label='Total Money') - ax1.fill_between(turns[-min_len:], total[-min_len:], - alpha=0.2, color=ChartColors.CYAN) + ax1.plot(turns[-min_len:], total[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Total') + ax1.fill_between(turns[-min_len:], total[-min_len:], alpha=0.2, color=ChartColors.CYAN) ax1_twin = ax1.twinx() - ax1_twin.plot(turns[-min_len:], avg[-min_len:], - color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg Wealth') - ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME) + ax1_twin.plot(turns[-min_len:], avg[-min_len:], color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg') + ax1_twin.set_ylabel('Avg', color=ChartColors.LIME) ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME) - ax1.set_title('Money in Circulation', color=ChartColors.YELLOW) - ax1.set_ylabel('Total Money', color=ChartColors.CYAN) + ax1.set_title('Money Circulation', color=ChartColors.YELLOW) + ax1.set_ylabel('Total', color=ChartColors.CYAN) ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN) ax1.grid(True, alpha=0.2) ax1.set_ylim(bottom=0) - # Gini coefficient (inequality) - min_len_gini = min(len(turns), len(gini)) - ax2.fill_between(turns[-min_len_gini:], gini[-min_len_gini:], - alpha=0.4, color=ChartColors.MAGENTA) - ax2.plot(turns[-min_len_gini:], gini[-min_len_gini:], - color=ChartColors.MAGENTA, linewidth=1.5) + min_gini = min(len(turns), len(gini)) + ax2.fill_between(turns[-min_gini:], gini[-min_gini:], alpha=0.4, color=ChartColors.MAGENTA) + ax2.plot(turns[-min_gini:], gini[-min_gini:], color=ChartColors.MAGENTA, linewidth=1.5) ax2.set_xlabel('Turn') ax2.set_ylabel('Gini') - ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9) + ax2.set_title('Inequality', color=ChartColors.MAGENTA, fontsize=9) ax2.set_ylim(0, 1) + ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5) ax2.grid(True, alpha=0.2) - # Add reference lines for gini - ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5, linewidth=1) - ax2.text(turns[-1] if turns else 0, 0.42, 'Moderate', fontsize=7, - color=ChartColors.YELLOW, alpha=0.7) - fig.tight_layout() return self._fig_to_surface(fig) - def render_professions(self, state: "SimulationState", history: HistoryData, - width: int, height: int) -> pygame.Surface: - """Render profession distribution as pie chart and area chart.""" - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) + def render_factions(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface: + """Render faction distribution and history.""" + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi) - # Current profession pie chart - professions = state.statistics.get("professions", {}) - if professions: - labels = list(professions.keys()) - sizes = list(professions.values()) - colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))] + # Current pie chart + faction_stats = state.get_faction_stats() + if faction_stats: + labels = list(faction_stats.keys()) + sizes = list(faction_stats.values()) + colors = [ChartColors.FACTIONS.get(f, "#787878") for f in labels] wedges, texts, autotexts = ax1.pie( - sizes, labels=labels, colors=colors, autopct='%1.0f%%', - startangle=90, pctdistance=0.75, + sizes, labels=[l.title() for l in labels], colors=colors, + autopct='%1.0f%%', startangle=90, pctdistance=0.75, textprops={'fontsize': 8, 'color': ChartColors.TEXT} ) for autotext in autotexts: autotext.set_color(ChartColors.BG) autotext.set_fontweight('bold') - ax1.set_title('Current Distribution', color=ChartColors.PURPLE, fontsize=10) + ax1.set_title('Faction Distribution', color=ChartColors.ORANGE, fontsize=10) else: ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM) - ax1.set_title('Current Distribution', color=ChartColors.PURPLE) - # Profession history as stacked area + # History stacked area turns = list(history.turns) if history.turns else [0] - if history.professions and turns: - profs_list = list(history.professions.keys()) + if history.factions and turns: + faction_list = list(history.factions.keys()) data = [] - for prof in profs_list: - prof_data = list(history.professions[prof]) - # Pad to match turns length - while len(prof_data) < len(turns): - prof_data.insert(0, 0) - data.append(prof_data[-len(turns):]) + for f in faction_list: + f_data = list(history.factions[f]) + while len(f_data) < len(turns): + f_data.insert(0, 0) + data.append(f_data[-len(turns):]) - colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(profs_list))] - ax2.stackplot(turns, *data, labels=profs_list, colors=colors, alpha=0.8) + colors = [ChartColors.FACTIONS.get(f, "#787878") for f in faction_list] + ax2.stackplot(turns, *data, labels=[f.title() for f in faction_list], colors=colors, alpha=0.8) ax2.legend(loc='upper left', fontsize=7, framealpha=0.8) ax2.set_xlabel('Turn') ax2.set_ylabel('Count') - ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10) + ax2.set_title('Faction History', color=ChartColors.ORANGE, fontsize=10) + ax2.grid(True, alpha=0.2) + fig.tight_layout() + return self._fig_to_surface(fig) + + def render_religions(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface: + """Render religion distribution and faith levels.""" + fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi) + ax1, ax2, ax3, ax4 = axes.flat + + # Current pie chart + religion_stats = state.get_religion_stats() + if religion_stats: + labels = list(religion_stats.keys()) + sizes = list(religion_stats.values()) + colors = [ChartColors.RELIGIONS.get(r, "#8c8c8c") for r in labels] + + wedges, texts, autotexts = ax1.pie( + sizes, labels=[l.title() for l in labels], colors=colors, + autopct='%1.0f%%', startangle=90, pctdistance=0.75, + textprops={'fontsize': 7, 'color': ChartColors.TEXT} + ) + for autotext in autotexts: + autotext.set_color(ChartColors.BG) + autotext.set_fontweight('bold') + autotext.set_fontsize(6) + ax1.set_title('Religion Distribution', color=ChartColors.PURPLE, fontsize=9) + + # Religion history + turns = list(history.turns) if history.turns else [0] + if history.religions and turns: + religion_list = list(history.religions.keys()) + data = [] + for r in religion_list: + r_data = list(history.religions[r]) + while len(r_data) < len(turns): + r_data.insert(0, 0) + data.append(r_data[-len(turns):]) + + colors = [ChartColors.RELIGIONS.get(r, "#8c8c8c") for r in religion_list] + ax2.stackplot(turns, *data, labels=[r.title() for r in religion_list], colors=colors, alpha=0.8) + ax2.legend(loc='upper left', fontsize=6, framealpha=0.8) + ax2.set_xlabel('Turn') + ax2.set_title('Religion History', color=ChartColors.PURPLE, fontsize=9) ax2.grid(True, alpha=0.2) + # Faith distribution histogram + agents = state.get_living_agents() + if agents: + faiths = [a.get("faith", 50) for a in agents] + ax3.hist(faiths, bins=20, color=ChartColors.PURPLE, alpha=0.7, edgecolor=ChartColors.PANEL) + avg_faith = np.mean(faiths) + ax3.axvline(x=avg_faith, color=ChartColors.YELLOW, linestyle='--', label=f'Avg: {avg_faith:.0f}') + ax3.legend(fontsize=7) + ax3.set_title('Faith Distribution', color=ChartColors.PURPLE, fontsize=9) + ax3.set_xlabel('Faith Level') + ax3.set_xlim(0, 100) + ax3.grid(True, alpha=0.2) + + # Average faith over time + if history.avg_faith and turns: + min_len = min(len(turns), len(history.avg_faith)) + faith_data = list(history.avg_faith)[-min_len:] + ax4.fill_between(list(turns)[-min_len:], faith_data, alpha=0.3, color=ChartColors.PURPLE) + ax4.plot(list(turns)[-min_len:], faith_data, color=ChartColors.PURPLE, linewidth=2) + ax4.set_title('Avg Faith Over Time', color=ChartColors.PURPLE, fontsize=9) + ax4.set_xlabel('Turn') + ax4.set_ylim(0, 100) + ax4.grid(True, alpha=0.2) + fig.tight_layout() return self._fig_to_surface(fig) - def render_market_activity(self, state: "SimulationState", history: HistoryData, - width: int, height: int) -> pygame.Surface: - """Render market activity - orders by resource, supply/demand.""" - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) + def render_diplomacy(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface: + """Render diplomacy stats - wars and peace treaties.""" + fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi) + ax1, ax2, ax3, ax4 = axes.flat - # Current market orders by resource type - prices = state.market_prices - resources = [] - quantities = [] - colors = [] + turns = list(history.turns) if history.turns else [0] - for i, (resource, data) in enumerate(prices.items()): - qty = data.get("total_available", 0) - if qty > 0: - resources.append(resource.capitalize()) - quantities.append(qty) - colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)]) + # Wars over time + if history.active_wars: + min_len = min(len(turns), len(history.active_wars)) + wars_data = list(history.active_wars)[-min_len:] + ax1.fill_between(list(turns)[-min_len:], wars_data, alpha=0.4, color=ChartColors.MAGENTA) + ax1.plot(list(turns)[-min_len:], wars_data, color=ChartColors.MAGENTA, linewidth=2) + ax1.set_title('Active Wars', color=ChartColors.MAGENTA, fontsize=10) + ax1.set_xlabel('Turn') + ax1.set_ylim(bottom=0) + ax1.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) + ax1.grid(True, alpha=0.2) - if resources: - bars = ax1.bar(resources, quantities, color=colors, alpha=0.85) - ax1.set_ylabel('Available') - for bar, val in zip(bars, quantities): - ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3, - str(val), ha='center', fontsize=8, color=ChartColors.TEXT) + # Peace treaties over time + if history.peace_treaties: + min_len = min(len(turns), len(history.peace_treaties)) + peace_data = list(history.peace_treaties)[-min_len:] + ax2.fill_between(list(turns)[-min_len:], peace_data, alpha=0.4, color=ChartColors.LIME) + ax2.plot(list(turns)[-min_len:], peace_data, color=ChartColors.LIME, linewidth=2) + ax2.set_title('Peace Treaties', color=ChartColors.LIME, fontsize=10) + ax2.set_xlabel('Turn') + ax2.set_ylim(bottom=0) + ax2.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) + ax2.grid(True, alpha=0.2) + + # Current wars list + active_wars = state.active_wars + if active_wars: + war_text = '\n'.join([ + f"{w.get('faction1', '?')[:8]} vs {w.get('faction2', '?')[:8]}" + for w in active_wars[:6] + ]) + ax3.text(0.5, 0.5, war_text, ha='center', va='center', + fontsize=9, color=ChartColors.MAGENTA, + transform=ax3.transAxes, family='monospace') else: - ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM) + ax3.text(0.5, 0.5, '☮ No Active Wars', ha='center', va='center', + fontsize=12, color=ChartColors.LIME, transform=ax3.transAxes) + ax3.set_title('Current Conflicts', color=ChartColors.MAGENTA, fontsize=10) + ax3.set_xticks([]) + ax3.set_yticks([]) - ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10) - ax1.tick_params(axis='x', rotation=45, labelsize=7) - ax1.grid(True, alpha=0.2, axis='y') - - # Supply/Demand scores - resources_sd = [] - supply_scores = [] - demand_scores = [] - - for resource, data in prices.items(): - resources_sd.append(resource[:6]) - supply_scores.append(data.get("supply_score", 0.5)) - demand_scores.append(data.get("demand_score", 0.5)) - - if resources_sd: - x = np.arange(len(resources_sd)) - width_bar = 0.35 + # Faction relations heatmap (simplified) + relations = state.faction_relations + if relations: + factions = list(relations.keys())[:5] # Top 5 factions + n = len(factions) + matrix = np.zeros((n, n)) - ax2.bar(x - width_bar/2, supply_scores, width_bar, label='Supply', - color=ChartColors.CYAN, alpha=0.8) - ax2.bar(x + width_bar/2, demand_scores, width_bar, label='Demand', - color=ChartColors.MAGENTA, alpha=0.8) + for i, f1 in enumerate(factions): + for j, f2 in enumerate(factions): + if f1 in relations and f2 in relations.get(f1, {}): + rel_data = relations[f1].get(f2, {}) + if isinstance(rel_data, dict): + matrix[i, j] = rel_data.get("value", 50) + else: + matrix[i, j] = 50 + elif i == j: + matrix[i, j] = 100 + else: + matrix[i, j] = 50 - ax2.set_xticks(x) - ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45) - ax2.set_ylabel('Score') - ax2.legend(fontsize=7) - ax2.set_ylim(0, 1.2) - - ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10) - ax2.grid(True, alpha=0.2, axis='y') + im = ax4.imshow(matrix, cmap='RdYlGn', vmin=0, vmax=100) + ax4.set_xticks(range(n)) + ax4.set_yticks(range(n)) + ax4.set_xticklabels([f[:5] for f in factions], fontsize=7, rotation=45) + ax4.set_yticklabels([f[:5] for f in factions], fontsize=7) + plt.colorbar(im, ax=ax4, shrink=0.8) + ax4.set_title('Relations', color=ChartColors.CYAN, fontsize=10) fig.tight_layout() return self._fig_to_surface(fig) - def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: - """Render aggregate agent statistics - energy, hunger, thirst distributions.""" - fig, axes = plt.subplots(2, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) + def render_agent_stats(self, state: "SimulationState", w: int, h: int) -> pygame.Surface: + """Render agent stats distribution.""" + fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi) - agents = [a for a in state.agents if a.get("is_alive", False)] + agents = state.get_living_agents() if not agents: for ax in axes.flat: @@ -467,57 +569,92 @@ class ChartRenderer: fig.tight_layout() return self._fig_to_surface(fig) - # Extract stats energies = [a.get("stats", {}).get("energy", 0) for a in agents] hungers = [a.get("stats", {}).get("hunger", 0) for a in agents] thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents] heats = [a.get("stats", {}).get("heat", 0) for a in agents] - max_energy = agents[0].get("stats", {}).get("max_energy", 100) - max_hunger = agents[0].get("stats", {}).get("max_hunger", 100) - max_thirst = agents[0].get("stats", {}).get("max_thirst", 100) - max_heat = agents[0].get("stats", {}).get("max_heat", 100) - stats_data = [ - (energies, max_energy, 'Energy', ChartColors.LIME), - (hungers, max_hunger, 'Hunger', ChartColors.ORANGE), - (thirsts, max_thirst, 'Thirst', ChartColors.CYAN), - (heats, max_heat, 'Heat', ChartColors.MAGENTA), + (energies, 100, 'Energy', ChartColors.LIME), + (hungers, 100, 'Hunger', ChartColors.ORANGE), + (thirsts, 100, 'Thirst', ChartColors.CYAN), + (heats, 100, 'Heat', ChartColors.MAGENTA), ] for ax, (values, max_val, name, color) in zip(axes.flat, stats_data): - # Histogram bins = np.linspace(0, max_val, 11) ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL) - # Mean line mean_val = np.mean(values) - ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--', - linewidth=1.5, label=f'Avg: {mean_val:.0f}') + ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--', linewidth=1.5, label=f'Avg: {mean_val:.0f}') - # Critical threshold critical = max_val * 0.25 - ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':', - linewidth=1, alpha=0.7) + ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':', linewidth=1, alpha=0.7) ax.set_title(name, color=color, fontsize=9) ax.set_xlim(0, max_val) ax.legend(fontsize=7, loc='upper right') ax.grid(True, alpha=0.2) - fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11) + fig.suptitle('Agent Statistics', color=ChartColors.CYAN, fontsize=11) + fig.tight_layout() + return self._fig_to_surface(fig) + + def render_market_activity(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface: + """Render market activity charts.""" + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi) + + # Market supply + prices = state.market_prices + resources = [] + quantities = [] + colors = [] + + for i, (resource, data) in enumerate(prices.items()): + qty = data.get("total_available", 0) + if qty > 0: + resources.append(resource.title()[:7]) + quantities.append(qty) + colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)]) + + if resources: + bars = ax1.bar(resources, quantities, color=colors, alpha=0.85) + ax1.set_ylabel('Available') + for bar, val in zip(bars, quantities): + ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3, + str(val), ha='center', fontsize=7, color=ChartColors.TEXT) + else: + ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM) + + ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10) + ax1.tick_params(axis='x', rotation=45, labelsize=7) + ax1.grid(True, alpha=0.2, axis='y') + + # Trade volume over time + turns = list(history.turns) if history.turns else [0] + if history.trade_volume: + min_len = min(len(turns), len(history.trade_volume)) + volume_data = list(history.trade_volume)[-min_len:] + ax2.fill_between(list(turns)[-min_len:], volume_data, alpha=0.3, color=ChartColors.TEAL) + ax2.plot(list(turns)[-min_len:], volume_data, color=ChartColors.TEAL, linewidth=2) + ax2.set_title('Trade Volume', color=ChartColors.TEAL, fontsize=10) + ax2.set_xlabel('Turn') + ax2.grid(True, alpha=0.2) + fig.tight_layout() return self._fig_to_surface(fig) class StatsRenderer: - """Main statistics panel with tabs and charts.""" + """Main statistics panel with tabbed interface.""" TABS = [ + ("Population", "population"), ("Prices", "price_history"), ("Wealth", "wealth"), - ("Population", "population"), - ("Professions", "professions"), + ("Factions", "factions"), + ("Religion", "religions"), + ("Diplomacy", "diplomacy"), ("Market", "market"), ("Agent Stats", "agent_stats"), ] @@ -526,40 +663,33 @@ class StatsRenderer: self.screen = screen self.visible = False - 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.current_tab = 0 self.tab_hovered = -1 - # History data self.history = HistoryData() - - # Chart renderer self.chart_renderer: Optional[ChartRenderer] = None - # Cached chart surfaces self._chart_cache: dict[str, pygame.Surface] = {} self._cache_turn: int = -1 - # Layout self._calculate_layout() def _calculate_layout(self) -> None: - """Calculate panel layout based on screen size.""" + """Calculate panel layout.""" screen_w, screen_h = self.screen.get_size() - # Panel takes most of the screen with some margin - margin = 30 + margin = 40 self.panel_rect = pygame.Rect( margin, margin, screen_w - margin * 2, screen_h - margin * 2 ) - # Tab bar - self.tab_height = 40 + self.tab_height = 36 self.tab_rect = pygame.Rect( self.panel_rect.x, self.panel_rect.y, @@ -567,46 +697,39 @@ class StatsRenderer: self.tab_height ) - # Chart area self.chart_rect = pygame.Rect( - self.panel_rect.x + 10, - self.panel_rect.y + self.tab_height + 10, - self.panel_rect.width - 20, - self.panel_rect.height - self.tab_height - 20 + self.panel_rect.x + 15, + self.panel_rect.y + self.tab_height + 15, + self.panel_rect.width - 30, + self.panel_rect.height - self.tab_height - 45 ) - # Initialize chart renderer with chart area size - self.chart_renderer = ChartRenderer( - self.chart_rect.width, - self.chart_rect.height - ) - - # Calculate tab widths + self.chart_renderer = ChartRenderer(self.chart_rect.width, self.chart_rect.height) self.tab_width = self.panel_rect.width // len(self.TABS) def toggle(self) -> None: - """Toggle visibility of the stats panel.""" + """Toggle visibility.""" self.visible = not self.visible if self.visible: self._invalidate_cache() def update_history(self, state: "SimulationState") -> None: - """Update history data with new state.""" + """Update history data.""" if state: self.history.update(state) def clear_history(self) -> None: - """Clear all history data (e.g., on simulation reset).""" + """Clear history.""" self.history.clear() self._invalidate_cache() def _invalidate_cache(self) -> None: - """Invalidate chart cache to force re-render.""" + """Invalidate chart cache.""" self._chart_cache.clear() self._cache_turn = -1 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 @@ -617,7 +740,6 @@ class StatsRenderer: elif event.type == pygame.MOUSEBUTTONDOWN: if self._handle_click(event.pos): return True - # Consume clicks when visible return True elif event.type == pygame.KEYDOWN: @@ -636,9 +758,8 @@ class StatsRenderer: return False def _handle_mouse_motion(self, pos: tuple[int, int]) -> None: - """Handle mouse motion for tab hover effects.""" + """Handle mouse motion.""" self.tab_hovered = -1 - if self.tab_rect.collidepoint(pos): rel_x = pos[0] - self.tab_rect.x tab_idx = rel_x // self.tab_width @@ -646,7 +767,7 @@ class StatsRenderer: self.tab_hovered = tab_idx def _handle_click(self, pos: tuple[int, int]) -> bool: - """Handle mouse click. Returns True if click was on a tab.""" + """Handle click.""" if self.tab_rect.collidepoint(pos): rel_x = pos[0] - self.tab_rect.x tab_idx = rel_x // self.tab_width @@ -657,85 +778,78 @@ class StatsRenderer: return False def _render_chart(self, state: "SimulationState") -> pygame.Surface: - """Render the current tab's chart.""" + """Render current tab chart.""" tab_name, tab_key = self.TABS[self.current_tab] - # Check cache current_turn = state.turn if state else 0 if tab_key in self._chart_cache and self._cache_turn == current_turn: return self._chart_cache[tab_key] - # Render chart based on current tab - width = self.chart_rect.width - height = self.chart_rect.height + w, h = self.chart_rect.width, self.chart_rect.height - if tab_key == "price_history": - surface = self.chart_renderer.render_price_history(self.history, width, height) + if tab_key == "population": + surface = self.chart_renderer.render_population(self.history, w, h) + elif tab_key == "price_history": + surface = self.chart_renderer.render_price_history(self.history, w, h) elif tab_key == "wealth": - # Split into two charts - half_height = height // 2 - dist_surface = self.chart_renderer.render_wealth_distribution(state, width, half_height) - time_surface = self.chart_renderer.render_wealth_over_time(self.history, width, half_height) - - surface = pygame.Surface((width, height)) + half_h = h // 2 + dist = self.chart_renderer.render_wealth_distribution(state, w, half_h) + time_chart = self.chart_renderer.render_wealth_over_time(self.history, w, half_h) + surface = pygame.Surface((w, h)) surface.fill(UIColors.BG) - surface.blit(dist_surface, (0, 0)) - surface.blit(time_surface, (0, half_height)) - elif tab_key == "population": - surface = self.chart_renderer.render_population(self.history, width, height) - elif tab_key == "professions": - surface = self.chart_renderer.render_professions(state, self.history, width, height) + surface.blit(dist, (0, 0)) + surface.blit(time_chart, (0, half_h)) + elif tab_key == "factions": + surface = self.chart_renderer.render_factions(state, self.history, w, h) + elif tab_key == "religions": + surface = self.chart_renderer.render_religions(state, self.history, w, h) + elif tab_key == "diplomacy": + surface = self.chart_renderer.render_diplomacy(state, self.history, w, h) elif tab_key == "market": - surface = self.chart_renderer.render_market_activity(state, self.history, width, height) + surface = self.chart_renderer.render_market_activity(state, self.history, w, h) elif tab_key == "agent_stats": - surface = self.chart_renderer.render_agent_stats(state, width, height) + surface = self.chart_renderer.render_agent_stats(state, w, h) else: - # Fallback empty surface - surface = pygame.Surface((width, height)) + surface = pygame.Surface((w, h)) surface.fill(UIColors.BG) - # Cache the result self._chart_cache[tab_key] = surface self._cache_turn = current_turn - return surface def draw(self, state: "SimulationState") -> None: - """Draw the statistics panel.""" + """Draw statistics panel.""" if not self.visible: return # Dim background overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) - overlay.fill((0, 0, 0, 220)) + overlay.fill((0, 0, 0, 230)) self.screen.blit(overlay, (0, 0)) # Panel background - pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=12) - pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=12) + pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=10) + pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=10) - # Draw tabs + # Tabs self._draw_tabs() - # Draw chart + # Chart if state: - chart_surface = self._render_chart(state) - self.screen.blit(chart_surface, self.chart_rect.topleft) + chart = self._render_chart(state) + self.screen.blit(chart, self.chart_rect.topleft) - # Draw close hint - hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs", - True, UIColors.TEXT_SECONDARY) - hint_rect = hint.get_rect(centerx=self.panel_rect.centerx, - y=self.panel_rect.bottom - 25) + # Close hint + hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs", True, UIColors.TEXT_SECONDARY) + hint_rect = hint.get_rect(centerx=self.panel_rect.centerx, y=self.panel_rect.bottom - 22) self.screen.blit(hint, hint_rect) def _draw_tabs(self) -> None: - """Draw the tab bar.""" + """Draw tab bar.""" for i, (tab_name, _) in enumerate(self.TABS): tab_x = self.tab_rect.x + i * self.tab_width tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height) - # Tab background if i == self.current_tab: color = UIColors.TAB_ACTIVE elif i == self.tab_hovered: @@ -743,28 +857,27 @@ class StatsRenderer: else: color = UIColors.TAB_INACTIVE - # Draw tab with rounded top corners tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA) - pygame.draw.rect(tab_surface, color, (0, 0, self.tab_width, self.tab_height), - border_top_left_radius=8, border_top_right_radius=8) + pygame.draw.rect( + tab_surface, color, (0, 0, self.tab_width, self.tab_height), + border_top_left_radius=6, border_top_right_radius=6 + ) if i == self.current_tab: - # Active tab - solid color tab_surface.set_alpha(255) else: tab_surface.set_alpha(180) self.screen.blit(tab_surface, (tab_x, self.tab_rect.y)) - # Tab text text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY text = self.small_font.render(tab_name, True, text_color) text_rect = text.get_rect(center=tab_rect.center) self.screen.blit(text, text_rect) - # Tab border - if i != self.current_tab: - pygame.draw.line(self.screen, UIColors.PANEL_BORDER, - (tab_x + self.tab_width - 1, self.tab_rect.y + 5), - (tab_x + self.tab_width - 1, self.tab_rect.y + self.tab_height - 5)) - + if i != self.current_tab and i < len(self.TABS) - 1: + pygame.draw.line( + self.screen, UIColors.PANEL_BORDER, + (tab_x + self.tab_width - 1, self.tab_rect.y + 6), + (tab_x + self.tab_width - 1, self.tab_rect.y + self.tab_height - 6) + ) diff --git a/frontend/renderer/ui_renderer.py b/frontend/renderer/ui_renderer.py index cfdc2b1..0a0a5cc 100644 --- a/frontend/renderer/ui_renderer.py +++ b/frontend/renderer/ui_renderer.py @@ -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) diff --git a/tools/debug_diplomacy.py b/tools/debug_diplomacy.py new file mode 100644 index 0000000..e8159f2 --- /dev/null +++ b/tools/debug_diplomacy.py @@ -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() + diff --git a/tools/optimize_balance.py b/tools/optimize_balance.py new file mode 100644 index 0000000..b98769b --- /dev/null +++ b/tools/optimize_balance.py @@ -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() + diff --git a/tools/test_religion_diplomacy.py b/tools/test_religion_diplomacy.py new file mode 100644 index 0000000..aae349d --- /dev/null +++ b/tools/test_religion_diplomacy.py @@ -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) + -- 2.47.1