255 lines
9.0 KiB
Python
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)
|
|
|