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