"""World container for the Village Simulation. The world spawns diverse agents with varied personality traits, skills, and starting conditions to create emergent professions and class inequality. """ import random from dataclasses import dataclass, field from enum import Enum from typing import Optional from backend.domain.agent import Agent, Position, Profession from backend.domain.personality import ( PersonalityTraits, Skills, generate_random_personality, generate_random_skills ) class TimeOfDay(Enum): """Current time of day in the simulation.""" DAY = "day" NIGHT = "night" def _get_world_config_from_file(): """Load world configuration from config.json.""" from backend.config import get_config return get_config().world @dataclass class WorldConfig: """Configuration for the world. Default values are loaded from config.json via create_world_config(). These hardcoded defaults are only fallbacks. """ width: int = 25 height: int = 25 initial_agents: int = 25 day_steps: int = 10 night_steps: int = 1 def create_world_config() -> WorldConfig: """Factory function to create WorldConfig from config.json.""" cfg = _get_world_config_from_file() return WorldConfig( width=cfg.width, height=cfg.height, initial_agents=cfg.initial_agents, day_steps=cfg.day_steps, night_steps=cfg.night_steps, ) @dataclass class World: """Container for all entities in the simulation.""" config: WorldConfig = field(default_factory=create_world_config) agents: list[Agent] = field(default_factory=list) current_turn: int = 0 current_day: int = 1 step_in_day: int = 0 time_of_day: TimeOfDay = TimeOfDay.DAY # Statistics total_agents_spawned: int = 0 total_agents_died: int = 0 def spawn_agent( self, name: Optional[str] = None, profession: Optional[Profession] = None, position: Optional[Position] = None, archetype: Optional[str] = None, starting_money: Optional[int] = None, ) -> Agent: """Spawn a new agent in the world with unique personality. Args: name: Agent name (auto-generated if None) profession: Deprecated, now derived from personality position: Starting position (random if None) archetype: Personality archetype ("hunter", "gatherer", "trader", etc.) starting_money: Starting money (random with inequality if None) """ if position is None: position = Position( x=random.randint(0, self.config.width - 1), y=random.randint(0, self.config.height - 1), ) # Generate unique personality and skills personality = generate_random_personality(archetype) skills = generate_random_skills(personality) # 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 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 position=position, personality=personality, skills=skills, money=starting_money, ) self.agents.append(agent) self.total_agents_spawned += 1 return agent def get_agent(self, agent_id: str) -> Optional[Agent]: """Get an agent by ID.""" for agent in self.agents: if agent.id == agent_id: return agent return None 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. """ 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: """Advance the simulation time by one step.""" self.current_turn += 1 self.step_in_day += 1 total_steps = self.config.day_steps + self.config.night_steps if self.step_in_day > total_steps: 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 def is_night(self) -> bool: """Check if it's currently night.""" return self.time_of_day == TimeOfDay.NIGHT def get_living_agents(self) -> list[Agent]: """Get all living agents (excludes corpses).""" return [a for a in self.agents if a.is_alive() and not a.is_corpse()] def get_statistics(self) -> dict: """Get current world statistics including wealth distribution.""" living = self.get_living_agents() total_money = sum(a.money for a in living) # Count emergent professions (updated based on current skills) profession_counts = {} for agent in living: agent._update_profession() # Update based on current state prof = agent.profession.value profession_counts[prof] = profession_counts.get(prof, 0) + 1 # Calculate wealth inequality metrics if living: moneys = sorted([a.money for a in living]) avg_money = total_money / len(living) median_money = moneys[len(moneys) // 2] richest = moneys[-1] if moneys else 0 poorest = moneys[0] if moneys else 0 # Gini coefficient for inequality (0 = perfect equality, 1 = max inequality) n = len(moneys) if n > 1 and total_money > 0: sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys) gini = sum_of_diffs / (2 * n * total_money) else: gini = 0 else: avg_money = median_money = richest = poorest = gini = 0 return { "current_turn": self.current_turn, "current_day": self.current_day, "step_in_day": self.step_in_day, "time_of_day": self.time_of_day.value, "living_agents": len(living), "total_agents_spawned": self.total_agents_spawned, "total_agents_died": self.total_agents_died, "total_money_in_circulation": total_money, "professions": profession_counts, # Wealth inequality metrics "avg_money": round(avg_money, 1), "median_money": median_money, "richest_agent": richest, "poorest_agent": poorest, "gini_coefficient": round(gini, 3), } def get_state_snapshot(self) -> dict: """Get a full snapshot of the world state for API.""" return { "turn": self.current_turn, "day": self.current_day, "step_in_day": self.step_in_day, "time_of_day": self.time_of_day.value, "world_size": {"width": self.config.width, "height": self.config.height}, "agents": [a.to_dict() for a in self.agents], "statistics": self.get_statistics(), } 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) """ 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) + ["trader"] * max(1, n // 7) + ["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)