villsim/backend/core/world.py

413 lines
15 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.
NEW: World now supports religion and faction systems for realistic
social dynamics including religious diversity and geopolitical factions.
"""
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
)
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):
"""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."""
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:
"""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,
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."""
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
# 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,
name: Optional[str] = None,
profession: Optional[Profession] = None,
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.
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)
religion: Religious beliefs (random if None)
faction: Faction membership (random 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)
# 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
if starting_money is None:
from backend.config import get_config
base_money = get_config().world.starting_money
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,
position=position,
personality=personality,
skills=skills,
religion=religion,
diplomacy=faction,
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 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."""
dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()]
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
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."""
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
profession_counts = {}
for agent in living:
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])
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
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 = avg_faith = 0
# War status
active_wars = len(self.faction_relations.active_wars)
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 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:
"""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(),
# 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.
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
archetypes = (
["hunter"] * max(1, n // 7) +
["gatherer"] * max(1, n // 7) +
["trader"] * max(1, n // 7) +
["woodcutter"] * max(1, n // 10)
)
while len(archetypes) < n:
archetypes.append(None)
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)