villsim/backend/core/world.py

255 lines
9.0 KiB
Python

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